cctally 1.109.0 → 1.111.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 (47) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_cache.py +62 -13
  4. package/bin/_cctally_core.py +8 -1
  5. package/bin/_cctally_dashboard.py +422 -195
  6. package/bin/_cctally_dashboard_conversation.py +231 -40
  7. package/bin/_cctally_dashboard_envelope.py +48 -32
  8. package/bin/_cctally_dashboard_sources.py +503 -109
  9. package/bin/_cctally_db.py +44 -0
  10. package/bin/_cctally_diagnosis_sources.py +181 -15
  11. package/bin/_cctally_doctor.py +15 -3
  12. package/bin/_cctally_forecast.py +25 -12
  13. package/bin/_cctally_journal.py +59 -32
  14. package/bin/_cctally_milestone_history.py +65 -9
  15. package/bin/_cctally_parser.py +5 -0
  16. package/bin/_cctally_record.py +50 -12
  17. package/bin/_cctally_rederive.py +899 -30
  18. package/bin/_cctally_source_analytics.py +567 -133
  19. package/bin/_cctally_transcript.py +27 -2
  20. package/bin/_cctally_tui.py +39 -6
  21. package/bin/_cctally_update.py +420 -14
  22. package/bin/_lib_cache_report.py +7 -10
  23. package/bin/_lib_codex_conversation_query.py +286 -63
  24. package/bin/_lib_codex_metadata.py +207 -0
  25. package/bin/_lib_conversation_query.py +254 -37
  26. package/bin/_lib_doctor.py +16 -2
  27. package/bin/_lib_pricing.py +80 -276
  28. package/bin/_lib_rederive.py +903 -2
  29. package/bin/_lib_view_models.py +5 -0
  30. package/bin/cctally +18 -5
  31. package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
  32. package/dashboard/static/assets/{DoctorModal-BZGXOlc2.js → DoctorModal-DFY2iPDf.js} +1 -1
  33. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
  34. package/dashboard/static/assets/{ProjectsDrillPanel-KbO_KWa7.js → ProjectsDrillPanel-DAv39uSo.js} +1 -1
  35. package/dashboard/static/assets/{SourceDetailModal-BrOPLVRL.js → SourceDetailModal-DSAWo2Fp.js} +1 -1
  36. package/dashboard/static/assets/{UpdateModal-CYgEFAi4.js → UpdateModal-R_9dOVnh.js} +3 -3
  37. package/dashboard/static/assets/index-COhxe3vU.css +1 -0
  38. package/dashboard/static/assets/index-DdU62sov.js +13 -0
  39. package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
  40. package/dashboard/static/assets/{useKeymap-DQx_A1MB.js → useKeymap-CW7Up0Ay.js} +1 -1
  41. package/dashboard/static/dashboard.html +3 -3
  42. package/package.json +2 -1
  43. package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +0 -72
  44. package/dashboard/static/assets/ModalRoot-C_fvugTl.js +0 -1
  45. package/dashboard/static/assets/index-Cj_l7T7u.css +0 -1
  46. package/dashboard/static/assets/index-DLVwACUM.js +0 -13
  47. package/dashboard/static/assets/outlineNavigation-DXX-RjKl.js +0 -9
@@ -29,14 +29,21 @@ from _cctally_quota import (
29
29
  load_codex_quota_projection_certificate,
30
30
  )
31
31
  from _cctally_source_analytics import (
32
+ CodexProjectMetadataHealth,
33
+ CodexThreadMetadataInventory,
32
34
  QualifiedMetadataUnavailable,
33
35
  RootedCodexAccountingEntry,
34
36
  has_cached_codex_accounting_entries,
35
37
  load_cached_rooted_codex_accounting_entries,
36
38
  load_codex_project_metadata_health,
39
+ load_codex_thread_metadata_inventory,
37
40
  load_qualified_codex_entries,
38
41
  probe_codex_detail_metadata_health,
39
42
  )
43
+ from _lib_codex_metadata import (
44
+ codex_metadata_is_malformed,
45
+ decode_codex_project_metadata,
46
+ )
40
47
  import _lib_log
41
48
  import _lib_accounts
42
49
  import _lib_perf
@@ -2351,6 +2358,8 @@ def _codex_cache_report_wire(
2351
2358
  entries: Iterable[object],
2352
2359
  *,
2353
2360
  metadata: Mapping[tuple[str, str], Mapping[str, object]],
2361
+ by_accounting_identity: "Mapping[tuple[str, str, str], Mapping[str, object]] | None" = None,
2362
+ withhold_projects: bool = False,
2354
2363
  now_utc: dt.datetime,
2355
2364
  display_tz_name: str | None,
2356
2365
  speed: str,
@@ -2376,15 +2385,14 @@ def _codex_cache_report_wire(
2376
2385
  cutoff = now_utc - dt.timedelta(days=window_days)
2377
2386
  bucket_tz = crk._resolve_bucket_tz(display_tz)
2378
2387
 
2379
- def _tiered_cost(tokens: int, pricing: Mapping[str, object], base: str, above: str) -> float:
2388
+ def _tiered_cost(tokens: int, long_context: bool,
2389
+ pricing: Mapping[str, object], base: str, above: str) -> float:
2380
2390
  if tokens <= 0:
2381
2391
  return 0.0
2382
2392
  base_rate = float(pricing.get(base, 0.0) or 0.0)
2383
2393
  above_rate = pricing.get(above)
2384
- threshold = int(c.CODEX_TIERED_THRESHOLD)
2385
- if tokens > threshold and above_rate is not None:
2386
- return threshold * base_rate + (tokens - threshold) * float(above_rate)
2387
- return tokens * base_rate
2394
+ rate = float(above_rate) if long_context and above_rate is not None else base_rate
2395
+ return tokens * rate
2388
2396
 
2389
2397
  def _wrap_entry(entry: object) -> object | None:
2390
2398
  timestamp = getattr(entry, "timestamp", None)
@@ -2394,23 +2402,34 @@ def _codex_cache_report_wire(
2394
2402
  input_tokens = int(getattr(entry, "input_tokens", 0))
2395
2403
  cached_tokens = min(input_tokens, int(getattr(entry, "cached_input_tokens", 0)))
2396
2404
  uncached_tokens = max(0, input_tokens - cached_tokens)
2405
+ long_context = input_tokens > int(c.CODEX_TIERED_THRESHOLD)
2397
2406
  pricing, _is_fallback = c._resolve_codex_pricing(model)
2398
2407
  pricing = pricing or {}
2399
2408
  uncached_counterfactual = _tiered_cost(
2400
- cached_tokens, pricing,
2409
+ cached_tokens, long_context, pricing,
2401
2410
  "input_cost_per_token", "input_cost_per_token_above_272k_tokens",
2402
2411
  )
2403
2412
  cached_actual = _tiered_cost(
2404
- cached_tokens, pricing,
2413
+ cached_tokens, long_context, pricing,
2405
2414
  "cache_read_input_token_cost", "cache_read_input_token_cost_above_272k_tokens",
2406
2415
  )
2407
2416
  multiplier = c._codex_fast_multiplier(model) if speed == "fast" else 1.0
2408
2417
  saved = max(0.0, uncached_counterfactual - cached_actual) * multiplier
2409
- identity = (
2410
- str(getattr(entry, "source_root_key", "") or ""),
2411
- str(getattr(entry, "source_path", "") or ""),
2412
- )
2413
- item_metadata = metadata.get(identity) or {}
2418
+ # #845 §4.4: in the partial generation the lookup is by accounting
2419
+ # identity, so one rollout path hosting a healthy and a malformed
2420
+ # conversation does not publish one decision for both. An entry the
2421
+ # identity map leaves unattributed lands in this fold's existing
2422
+ # `(unknown)` bucket, which is its not-attributed bucket rather than a
2423
+ # project.
2424
+ if by_accounting_identity is not None:
2425
+ item_metadata = by_accounting_identity.get(
2426
+ _codex_accounting_identity(entry)) or {}
2427
+ else:
2428
+ identity = (
2429
+ str(getattr(entry, "source_root_key", "") or ""),
2430
+ str(getattr(entry, "source_path", "") or ""),
2431
+ )
2432
+ item_metadata = metadata.get(identity) or {}
2414
2433
  project = (
2415
2434
  str(getattr(entry, "project_label", "") or "").strip()
2416
2435
  or str(item_metadata.get("project_label") or "").strip()
@@ -2776,6 +2795,12 @@ def _codex_cache_report_wire(
2776
2795
 
2777
2796
  by_project = _finish_breakdown("project")
2778
2797
  by_model = _finish_breakdown("model")
2798
+ if withhold_projects:
2799
+ # #845 §4.5. The conversation-metadata read failed, so no entry has a
2800
+ # project identity at all. Publishing an empty collection says that;
2801
+ # converting every entry to `(unknown)` would report a bucket the
2802
+ # reader cannot distinguish from a real absence of attribution.
2803
+ by_project = ()
2779
2804
  seven = days[:7]
2780
2805
  saved_total = stable_sum(float(row["saved_usd"]) for row in days)
2781
2806
  wasted_total = stable_sum(float(row["wasted_usd"]) for row in days)
@@ -2844,9 +2869,127 @@ _CODEX_FILE_ALIAS_SQL = (
2844
2869
  )
2845
2870
 
2846
2871
 
2872
+ @dataclass(frozen=True)
2873
+ class _CodexConversationMetadataRead:
2874
+ """The typed result of the conversation-metadata read (#845 §4.5).
2875
+
2876
+ ``metadata`` is the legacy path map, keyed ``(source_root_key,
2877
+ source_path)`` and built exactly as it was. ``by_accounting_identity`` is
2878
+ the per-conversation map of §4.4, keyed ``(source_root_key,
2879
+ conversation_key or "", source_path)`` and materialized only for the
2880
+ partial generation. ``error`` distinguishes a store with no Codex
2881
+ conversations, which returns two empty maps and ``None``, from a read that
2882
+ failed, which returns two empty maps and the exception: without it the two
2883
+ were byte-identical afterwards and the second published as the first.
2884
+ """
2885
+
2886
+ metadata: "Mapping[tuple[str, str], Mapping[str, object]]"
2887
+ by_accounting_identity: "Mapping[tuple[str, str, str], Mapping[str, object]]"
2888
+ error: "BaseException | None" = None
2889
+
2890
+
2891
+ def _codex_accounting_identity(entry: object) -> tuple[str, str, str]:
2892
+ """The identity map's key for one accounting entry, with no fallback.
2893
+
2894
+ The exact key for every entry: a sentinel fallback between this key and
2895
+ the path-keyed one would silently attribute a refused entry through a
2896
+ neighbour that shares its path.
2897
+ """
2898
+ return (
2899
+ str(getattr(entry, "source_root_key", "") or ""),
2900
+ str(getattr(entry, "conversation_key", "") or ""),
2901
+ str(getattr(entry, "source_path", "") or ""),
2902
+ )
2903
+
2904
+
2905
+ def _codex_identity_map(
2906
+ cache_conn: sqlite3.Connection,
2907
+ accounting_entries: "Iterable[object]",
2908
+ inventory: "CodexThreadMetadataInventory | None" = None,
2909
+ ) -> dict[tuple[str, str, str], dict[str, object]]:
2910
+ """Decide project attribution per accounting identity (§4.4).
2911
+
2912
+ The decision order is the qualified reader's, so the map, the counter and
2913
+ the reader agree on every rooted entry. First the reader's two refusals,
2914
+ which precede any metadata: an empty ``conversation_key``, whatever its
2915
+ path's alias winner holds, and a nonempty key with neither a thread row nor
2916
+ an alias winner. A refused item carries ``None`` for both fields; no
2917
+ resolver is called and the predicate is not consulted.
2918
+
2919
+ Every other entry is classified through ``codex_metadata_is_malformed``
2920
+ with its direct row and its path's inherited winner, and a malformed one
2921
+ carries ``None`` for both fields too. Otherwise the entry is attributed
2922
+ exactly as the qualified reader would attribute it: the merged fields
2923
+ ``cwd = direct.cwd or inherited.cwd`` and ``git_json = direct.git_json or
2924
+ inherited.git_json``, with the same ``inherited`` operand the decision
2925
+ used. An alias-only identity therefore attributes through the inventory's
2926
+ ordering-only winner rather than through the path map's native-id
2927
+ inheritance, so an unrelated malformed row can never relabel or remove a
2928
+ healthy project row.
2929
+
2930
+ The reader's first refusal, an empty ``source_root_key``, is not mirrored
2931
+ here: the rooted loader raises for such a row before any entry reaches this
2932
+ map (§4.6 rule 3).
2933
+ """
2934
+ from _cctally_cache import _codex_conversation_project_attribution
2935
+
2936
+ if inventory is None:
2937
+ inventory = load_codex_thread_metadata_inventory(cache_conn)
2938
+ decided: dict[tuple[str, str, str], dict[str, object]] = {}
2939
+ for entry in accounting_entries:
2940
+ identity = _codex_accounting_identity(entry)
2941
+ if identity in decided:
2942
+ continue
2943
+ root_key, conversation_key, source_path = identity
2944
+ direct = inventory.direct_by_conversation.get((root_key, conversation_key))
2945
+ inherited = inventory.inherited_by_path.get((root_key, source_path))
2946
+ refused = not conversation_key or (direct is None and inherited is None)
2947
+ if refused or codex_metadata_is_malformed(
2948
+ direct if conversation_key else None, inherited,
2949
+ ):
2950
+ decided[identity] = {"project_key": None, "project_label": None}
2951
+ continue
2952
+ cwd = (direct.cwd if direct is not None else None) or (
2953
+ inherited.cwd if inherited is not None else None)
2954
+ git_json = (direct.git_json if direct is not None else None) or (
2955
+ inherited.git_json if inherited is not None else None)
2956
+ project_key, project_label = _codex_conversation_project_attribution(
2957
+ root_key, cwd, git_json)
2958
+ decided[identity] = {
2959
+ "project_key": project_key, "project_label": project_label,
2960
+ }
2961
+ return decided
2962
+
2963
+
2964
+ def codex_unattributed_paths(
2965
+ accounting_entries: "Iterable[object]",
2966
+ by_accounting_identity: "Mapping[tuple[str, str, str], Mapping[str, object]]",
2967
+ ) -> "frozenset[tuple[str, str]]":
2968
+ """The ``(root, path)`` set whose session rows must publish NULL projects.
2969
+
2970
+ Computed per caller from the entries that built THAT session view — the
2971
+ parent over every fallback entry, each account child over its own
2972
+ partition — so a child whose entries carry no unattributed identity on a
2973
+ shared path keeps that path's session project while a child whose entries
2974
+ do receives NULL. A session row's single project field cannot represent a
2975
+ mixed path.
2976
+ """
2977
+ unattributed: set[tuple[str, str]] = set()
2978
+ for entry in accounting_entries:
2979
+ identity = _codex_accounting_identity(entry)
2980
+ item = by_accounting_identity.get(identity)
2981
+ if item is None or not item.get("project_key"):
2982
+ unattributed.add((identity[0], identity[2]))
2983
+ return frozenset(unattributed)
2984
+
2985
+
2847
2986
  def _codex_conversation_metadata(
2848
2987
  cache_conn: sqlite3.Connection,
2849
- ) -> dict[tuple[str, str], dict[str, object]]:
2988
+ *,
2989
+ accounting_entries: "Iterable[object]" = (),
2990
+ build_identity_map: bool = False,
2991
+ inventory: "CodexThreadMetadataInventory | None" = None,
2992
+ ) -> _CodexConversationMetadataRead:
2850
2993
  """Read task short names and cached project metadata by rooted rollout.
2851
2994
 
2852
2995
  ``state_5.sqlite.threads.title`` is Codex's persisted user-facing task name.
@@ -2866,7 +3009,9 @@ def _codex_conversation_metadata(
2866
3009
  ") "
2867
3010
  "SELECT t.source_root_key, t.source_path, t.native_thread_id, "
2868
3011
  "e.session_id AS accounting_session_id, "
2869
- "t.cwd, t.git_json, a.started_at, t.last_seen_utc "
3012
+ "CAST(t.cwd AS BLOB) AS cwd_blob, "
3013
+ "CAST(t.git_json AS BLOB) AS git_json_blob, "
3014
+ "a.started_at, t.last_seen_utc "
2870
3015
  "FROM codex_conversation_threads AS t "
2871
3016
  "LEFT JOIN accounting AS a "
2872
3017
  "ON a.source_root_key=t.source_root_key AND a.source_path=t.source_path "
@@ -2874,15 +3019,27 @@ def _codex_conversation_metadata(
2874
3019
  "ORDER BY t.last_seen_utc DESC, t.conversation_key DESC"
2875
3020
  ))
2876
3021
  from _cctally_cache import _codex_conversation_project_attribution
3022
+
3023
+ def _attribution(root_key, cwd_blob, git_json_blob):
3024
+ # #845 §4.1. The two columns arrive as BLOBs; a thread whose
3025
+ # resolver path reaches an undecodable field carries no attribution
3026
+ # at all rather than the `(unassigned)` its decoded `None` fields
3027
+ # would otherwise produce.
3028
+ decoded = decode_codex_project_metadata(cwd_blob, git_json_blob)
3029
+ if codex_metadata_is_malformed(decoded, None):
3030
+ return (None, None)
3031
+ return _codex_conversation_project_attribution(
3032
+ root_key, decoded.cwd, decoded.git_json)
3033
+
2877
3034
  rows = tuple(
2878
3035
  (
2879
3036
  root_key, source_path, native_thread_id, accounting_session_id,
2880
- *_codex_conversation_project_attribution(root_key, cwd, git_json),
3037
+ *_attribution(root_key, cwd_blob, git_json_blob),
2881
3038
  first_seen_at,
2882
3039
  )
2883
3040
  for (
2884
3041
  root_key, source_path, native_thread_id, accounting_session_id,
2885
- cwd, git_json, first_seen_at, _last_seen_at,
3042
+ cwd_blob, git_json_blob, first_seen_at, _last_seen_at,
2886
3043
  ) in core_rows
2887
3044
  )
2888
3045
  file_aliases = tuple(cache_conn.execute(_CODEX_FILE_ALIAS_SQL))
@@ -2981,9 +3138,16 @@ def _codex_conversation_metadata(
2981
3138
  "project_label": (inherited or {}).get("project_label"),
2982
3139
  "started_at": started_at or (inherited or {}).get("started_at"),
2983
3140
  }
2984
- except sqlite3.Error:
2985
- return {}
2986
- return metadata
3141
+ except sqlite3.Error as exc:
3142
+ return _CodexConversationMetadataRead({}, {}, exc)
3143
+ by_identity: dict[tuple[str, str, str], dict[str, object]] = {}
3144
+ if build_identity_map:
3145
+ try:
3146
+ by_identity = _codex_identity_map(
3147
+ cache_conn, accounting_entries, inventory)
3148
+ except sqlite3.Error as exc:
3149
+ return _CodexConversationMetadataRead({}, {}, exc)
3150
+ return _CodexConversationMetadataRead(metadata, by_identity, None)
2987
3151
 
2988
3152
 
2989
3153
  def _session_wire(
@@ -2991,7 +3155,17 @@ def _session_wire(
2991
3155
  *,
2992
3156
  metadata: Mapping[tuple[str, str], Mapping[str, object]] | None = None,
2993
3157
  private_labels: dict[str, str] | None = None,
3158
+ unattributed_paths: "frozenset[tuple[str, str]]" = frozenset(),
2994
3159
  ) -> dict[str, object]:
3160
+ """Fold one session view; ``unattributed_paths`` nulls a row's project.
3161
+
3162
+ #845 §4.4: a row's project fields are NULL exactly when its
3163
+ ``(source_root_key, source_path)`` is in that set, because the row's single
3164
+ project field cannot represent a path that hosts one healthy and one
3165
+ malformed conversation. Otherwise they come from the path map as they do
3166
+ today, so a healthy path's session row is unchanged in the partial
3167
+ generation. Titles and ``started_at`` always come from the path map.
3168
+ """
2995
3169
  rows = []
2996
3170
  for row in view.rows:
2997
3171
  # The Codex session aggregator intentionally splits equal relative
@@ -3023,7 +3197,15 @@ def _session_wire(
3023
3197
  if row_metadata and row_metadata.get("title") is not None
3024
3198
  else None
3025
3199
  )
3026
- project = str(row_metadata.get("project_label") or "").strip() if row_metadata else ""
3200
+ unattributed = (
3201
+ str(row.codex_root or ""), str(row.session_id_path),
3202
+ ) in unattributed_paths
3203
+ project = (
3204
+ ""
3205
+ if unattributed
3206
+ else str(row_metadata.get("project_label") or "").strip()
3207
+ if row_metadata else ""
3208
+ )
3027
3209
  started_at = row_metadata.get("started_at") if row_metadata else None
3028
3210
  duration_min = None
3029
3211
  if isinstance(started_at, str):
@@ -3041,7 +3223,10 @@ def _session_wire(
3041
3223
  "key": key,
3042
3224
  "source": "codex",
3043
3225
  "project": project or None,
3044
- "project_key": row_metadata.get("project_key") if row_metadata else None,
3226
+ "project_key": (
3227
+ None if unattributed
3228
+ else row_metadata.get("project_key") if row_metadata else None
3229
+ ),
3045
3230
  "started_at": started_at,
3046
3231
  "duration_min": duration_min,
3047
3232
  "last_activity": row.last_activity.astimezone(UTC).isoformat(),
@@ -4645,21 +4830,22 @@ def _cached_projects_wire(
4645
4830
 
4646
4831
  def _partial_projects_wire(
4647
4832
  entries: Iterable[object],
4648
- metadata: Mapping[tuple[str, str], Mapping[str, object]],
4833
+ by_accounting_identity: "Mapping[tuple[str, str, str], Mapping[str, object]]",
4649
4834
  ) -> dict[str, object]:
4650
4835
  """Aggregate the qualified subset when older accounting metadata is mixed.
4651
4836
 
4652
- Rows without a cached conversation/project identity are omitted and remain
4653
- covered by the Projects-domain warning. Valid projects stay visible; their
4654
- totals never include an unqualified accounting row.
4837
+ #845 §4.4: the lookup is by ACCOUNTING IDENTITY, not by path. One rollout
4838
+ path can host a healthy conversation and a malformed one, and a path-keyed
4839
+ map would publish one decision for both. An entry the identity map leaves
4840
+ unattributed — refused or malformed — carries a ``None`` item and is
4841
+ omitted here, where the Projects-domain warning covers it. Every other
4842
+ entry keeps the project key the healthy fold publishes for it.
4655
4843
  """
4656
4844
  groups: dict[tuple[str, str], dict[str, object]] = {}
4657
4845
  for entry in entries:
4658
- identity = (
4659
- str(getattr(entry, "source_root_key", "") or ""),
4660
- str(getattr(entry, "source_path", "") or ""),
4661
- )
4662
- row_metadata = metadata.get(identity)
4846
+ accounting_identity = _codex_accounting_identity(entry)
4847
+ identity = (accounting_identity[0], accounting_identity[2])
4848
+ row_metadata = by_accounting_identity.get(accounting_identity)
4663
4849
  project_key = str(row_metadata.get("project_key") or "").strip() if row_metadata else ""
4664
4850
  project_label = str(row_metadata.get("project_label") or "").strip() if row_metadata else ""
4665
4851
  if not project_key or not project_label:
@@ -5723,6 +5909,17 @@ def _codex_account_scopes_wire(
5723
5909
  accounting_end: dt.datetime,
5724
5910
  metadata_incomplete: bool,
5725
5911
  conversation_metadata: Mapping[tuple[str, str], Mapping[str, object]],
5912
+ #: #845 §4.4/§4.5. The per-conversation decisions of the partial
5913
+ #: generation, and whether the conversation-metadata read itself failed.
5914
+ #: Both default to the healthy shape so a direct caller that has neither
5915
+ #: keeps today's behaviour.
5916
+ conversation_identity_metadata: "Mapping[tuple[str, str, str], Mapping[str, object]]" = (
5917
+ MappingProxyType({})),
5918
+ conversation_metadata_failed: bool = False,
5919
+ #: §4.6 rule 1: a transient generation publishes NO Codex project rows on
5920
+ #: any child either, so a focused account cannot show rows the parent is
5921
+ #: withholding.
5922
+ metadata_transient: bool = False,
5726
5923
  alerts: Iterable[Mapping[str, object]],
5727
5924
  budget_milestones: Iterable[Mapping[str, object]],
5728
5925
  projected_budget_milestones: Iterable[Mapping[str, object]],
@@ -5883,9 +6080,19 @@ def _codex_account_scopes_wire(
5883
6080
  "sessions": _session_wire(
5884
6081
  sessions_view, metadata=conversation_metadata,
5885
6082
  private_labels=private_session_labels,
6083
+ # Each child computes the set over its OWN partition, so a
6084
+ # child whose entries carry no unattributed identity on a
6085
+ # shared path keeps that path's session project.
6086
+ unattributed_paths=(
6087
+ codex_unattributed_paths(
6088
+ rows, conversation_identity_metadata)
6089
+ if metadata_incomplete else frozenset()
6090
+ ),
5886
6091
  ),
5887
6092
  "projects": (
5888
- _partial_projects_wire(rows, conversation_metadata)
6093
+ _partial_projects_wire((), {})
6094
+ if metadata_transient
6095
+ else _partial_projects_wire(rows, conversation_identity_metadata)
5889
6096
  if metadata_incomplete else _cached_projects_wire(
5890
6097
  context, account_observations, rows,
5891
6098
  changed_old=changed_old_rows_by_account.get(key, ()),
@@ -5897,7 +6104,12 @@ def _codex_account_scopes_wire(
5897
6104
  )
5898
6105
  ),
5899
6106
  "cache_report": _codex_cache_report_wire(
5900
- rows, metadata=conversation_metadata, now_utc=context.now_utc,
6107
+ rows, metadata=conversation_metadata,
6108
+ by_accounting_identity=(
6109
+ conversation_identity_metadata if metadata_incomplete
6110
+ else None),
6111
+ withhold_projects=conversation_metadata_failed,
6112
+ now_utc=context.now_utc,
5901
6113
  display_tz_name=context.display_tz_name, speed=context.speed,
5902
6114
  anomaly_threshold_pp=context.cache_report_anomaly_threshold_pp,
5903
6115
  cache_key=("account", key),
@@ -7213,6 +7425,12 @@ class _CodexAccountingCapture:
7213
7425
  #: row that is deterministically unqualifiable and for a read that failed,
7214
7426
  #: and those want different explanations and different remedies.
7215
7427
  metadata_read_failed: bool = False
7428
+ #: #845/#846 §4.6. The fixed internal vocabulary of metadata legs that
7429
+ #: failed with a genuine `sqlite3.Error` or a `transient=True` refusal:
7430
+ #: `accounting_health`, `detail_probe`, `qualified_accounting`,
7431
+ #: `fallback_accounting`, `conversation_metadata`. Nothing is added to the wire; the combined
7432
+ #: verdict is derived from this tuple.
7433
+ failed_legs: tuple[str, ...] = ()
7216
7434
  #: The name of the population THIS capture read, taken from the accounting
7217
7435
  #: result rather than re-read from `_lib_snapshot_cache`'s module global at
7218
7436
  #: fold time. The global is process-wide, so a concurrent share render's
@@ -7225,44 +7443,63 @@ class _CodexAccountingCapture:
7225
7443
  population_signature: tuple | None = None
7226
7444
 
7227
7445
 
7228
- #: The detail routes' horizon: one year ending at the generation instant. It
7229
- #: is stated here because the probe has to use the EXACT window both routes
7230
- #: read (`_cctally_dashboard._codex_detail_context`), not a convenient
7231
- #: approximation of it.
7446
+ #: The detail routes' horizon: one year ending at the generation instant. The
7447
+ #: carrier probe covers its union with the accounting window, which may begin
7448
+ #: earlier for a configured budget.
7232
7449
  CODEX_DETAIL_HORIZON = dt.timedelta(days=365)
7233
7450
 
7234
7451
 
7235
- def _probe_codex_detail_metadata_health(
7236
- context: DashboardReadContext,
7237
- ) -> "Mapping[str, object]":
7238
- """Run the ONE detail-horizon metadata probe this generation carries.
7452
+ def _codex_build_metadata_inventory(
7453
+ cache_conn: sqlite3.Connection,
7454
+ ) -> "CodexThreadMetadataInventory | None":
7455
+ """The ONE thread-and-alias inventory this build's consumers share (§4.3).
7456
+
7457
+ The health counter, the detail probe's counter, the qualified reader and
7458
+ the identity map each used to load it for themselves, so a build paid the
7459
+ whole-table thread pass three or four times over. They now receive this
7460
+ one.
7461
+
7462
+ A read failure answers ``None`` rather than raising. Each consumer then
7463
+ loads the inventory itself and fails inside its OWN classification `try`,
7464
+ exactly as it did before this call existed, so the set of failed legs
7465
+ §4.6 rule 1 composes is unchanged by the sharing.
7466
+ """
7467
+ try:
7468
+ return load_codex_thread_metadata_inventory(cache_conn)
7469
+ except (sqlite3.Error, RuntimeError):
7470
+ return None
7239
7471
 
7240
- #834 S2 (#828). Neither detail route queries independently and no
7241
- ingest-maintained marker is introduced: the generation is built once, this
7242
- runs inside it, and both routes read the frozen result.
7243
7472
 
7244
- A bare SQLite error here is a failure of the READ, not a property of the
7245
- rows, so it publishes `transient_read_failure` — the build could not check
7246
- metadata health and will retry on the next refresh. Telling the reader to
7247
- rebuild the cache would name a remedy for a problem they do not have.
7473
+ def _probe_codex_detail_metadata_health_leg(
7474
+ context: DashboardReadContext,
7475
+ inventory: "CodexThreadMetadataInventory | None" = None,
7476
+ accounting_start: dt.datetime | None = None,
7477
+ accounting_end: dt.datetime | None = None,
7478
+ ) -> "tuple[Mapping[str, object], BaseException | None]":
7479
+ """The probe leg, with its failure REPORTED rather than absorbed.
7480
+
7481
+ #846 §4.6: a failure here is one of four legs the combined verdict weighs,
7482
+ so the exception is handed back instead of being turned into a carrier on
7483
+ the spot. The published carrier for the failed case is the same
7484
+ `transient_read_failure` this leg published on its own before.
7248
7485
  """
7249
7486
  try:
7250
7487
  probe = probe_codex_detail_metadata_health(
7251
7488
  cache_conn=context.cache_conn,
7252
- start=context.now_utc - CODEX_DETAIL_HORIZON,
7253
- end=context.now_utc,
7489
+ start=min(context.now_utc - CODEX_DETAIL_HORIZON, accounting_start)
7490
+ if accounting_start is not None
7491
+ else context.now_utc - CODEX_DETAIL_HORIZON,
7492
+ end=max(context.now_utc, accounting_end)
7493
+ if accounting_end is not None else context.now_utc,
7494
+ inventory=inventory,
7254
7495
  )
7255
- except (sqlite3.Error, RuntimeError):
7256
- _lib_log.get_logger("dashboard").warning(
7257
- "Codex detail metadata probe failed; publishing a retryable "
7258
- "transient health result for this generation"
7259
- )
7260
- return build_metadata_health("transient_read_failure")
7496
+ except (sqlite3.Error, RuntimeError) as exc:
7497
+ return build_metadata_health("transient_read_failure"), exc
7261
7498
  if probe.incomplete_rows > 0:
7262
7499
  return build_metadata_health(
7263
7500
  "malformed_row_partial", incomplete_rows=probe.incomplete_rows,
7264
- )
7265
- return build_metadata_health("healthy")
7501
+ ), None
7502
+ return build_metadata_health("healthy"), None
7266
7503
 
7267
7504
 
7268
7505
  def _capture_codex_accounting(
@@ -7273,10 +7510,13 @@ def _capture_codex_accounting(
7273
7510
  active_roots: tuple[str, ...],
7274
7511
  path_scope: object,
7275
7512
  incomplete_rows: int,
7513
+ failed_legs: tuple[str, ...] = (),
7514
+ leg_errors: "list[tuple[str, BaseException]] | None" = None,
7515
+ inventory: "CodexThreadMetadataInventory | None" = None,
7276
7516
  ) -> _CodexAccountingCapture:
7277
7517
  """Read the incremental accounting carrier without folding public views.
7278
7518
 
7279
- #834 S2 (#828, #829): the caller passes the probe's ROW COUNT rather than
7519
+ #834 S2 (#828, #829): the caller passes the health read's ROW COUNT rather than
7280
7520
  a boolean, because the typed health result this capture returns has to
7281
7521
  report the count a malformed-row partial measured, and a boolean has
7282
7522
  already thrown it away.
@@ -7292,7 +7532,29 @@ def _capture_codex_accounting(
7292
7532
  changed_old: tuple[object, ...] = ()
7293
7533
  changed_new: tuple[object, ...] = ()
7294
7534
  population_signature: tuple | None = None
7295
- if not metadata_incomplete:
7535
+
7536
+ def fallback_entries() -> tuple[object, ...]:
7537
+ nonlocal metadata_read_failed, failed_legs
7538
+ try:
7539
+ return load_cached_rooted_codex_accounting_entries(
7540
+ accounting_start, accounting_end, speed=context.speed,
7541
+ cache_conn=context.cache_conn,
7542
+ )
7543
+ except QualifiedMetadataUnavailable as exc:
7544
+ # A failed SQLite read has no trustworthy rows. Keep the typed
7545
+ # transient carrier; deterministic identity refusals retain their
7546
+ # existing fail-loud path.
7547
+ if not isinstance(exc.__cause__, sqlite3.Error):
7548
+ raise
7549
+ metadata_read_failed = True
7550
+ failed_legs = (*failed_legs, "fallback_accounting")
7551
+ if leg_errors is not None:
7552
+ leg_errors.append(("fallback_accounting", exc))
7553
+ return ()
7554
+ # §4.6 rule 1: the qualified read is skipped when a leg before it has
7555
+ # already failed. Its result could not be published anyway, and issuing it
7556
+ # over a store whose metadata legs are failing only costs the tick.
7557
+ if not metadata_incomplete and not failed_legs:
7296
7558
  try:
7297
7559
  cached_accounting = _lib_snapshot_cache.build_cached_codex_accounting(
7298
7560
  cache_conn=context.cache_conn,
@@ -7310,6 +7572,7 @@ def _capture_codex_accounting(
7310
7572
  speed=context.speed,
7311
7573
  sync=False,
7312
7574
  cache_conn=context.cache_conn,
7575
+ inventory=inventory,
7313
7576
  ),
7314
7577
  load_paths=lambda identities: load_qualified_codex_entries(
7315
7578
  accounting_start,
@@ -7318,6 +7581,7 @@ def _capture_codex_accounting(
7318
7581
  sync=False,
7319
7582
  cache_conn=context.cache_conn,
7320
7583
  source_identities=identities,
7584
+ inventory=inventory,
7321
7585
  ),
7322
7586
  path_of=lambda entry: (
7323
7587
  str(entry.source_root_key), str(entry.source_path),
@@ -7350,13 +7614,12 @@ def _capture_codex_accounting(
7350
7614
  # class over a superset of this window, so its count is the
7351
7615
  # measured answer and this exception carries none.
7352
7616
  metadata_read_failed = getattr(exc, "transient", True)
7617
+ if metadata_read_failed:
7618
+ failed_legs = (*failed_legs, "qualified_accounting")
7619
+ if leg_errors is not None:
7620
+ leg_errors.append(("qualified_accounting", exc))
7353
7621
  _lib_snapshot_cache.reset_codex_accounting_cache_state()
7354
- entries = load_cached_rooted_codex_accounting_entries(
7355
- accounting_start,
7356
- accounting_end,
7357
- speed=context.speed,
7358
- cache_conn=context.cache_conn,
7359
- )
7622
+ entries = fallback_entries()
7360
7623
  dirty_accounts = tuple(sorted({
7361
7624
  str(getattr(entry, "account_key", "") or
7362
7625
  _lib_accounts.UNATTRIBUTED)
@@ -7364,12 +7627,7 @@ def _capture_codex_accounting(
7364
7627
  }))
7365
7628
  else:
7366
7629
  _lib_snapshot_cache.reset_codex_accounting_cache_state()
7367
- entries = load_cached_rooted_codex_accounting_entries(
7368
- accounting_start,
7369
- accounting_end,
7370
- speed=context.speed,
7371
- cache_conn=context.cache_conn,
7372
- )
7630
+ entries = fallback_entries()
7373
7631
  dirty_accounts = tuple(sorted({
7374
7632
  str(getattr(entry, "account_key", "") or
7375
7633
  _lib_accounts.UNATTRIBUTED)
@@ -7383,6 +7641,7 @@ def _capture_codex_accounting(
7383
7641
  entries=tuple(entries),
7384
7642
  metadata_incomplete=metadata_incomplete,
7385
7643
  metadata_read_failed=metadata_read_failed,
7644
+ failed_legs=failed_legs,
7386
7645
  population_signature=(
7387
7646
  None if metadata_incomplete else population_signature),
7388
7647
  )
@@ -7483,7 +7742,10 @@ class CodexSourceCapture:
7483
7742
  accounting: _CodexAccountingCapture
7484
7743
  accounting_exists: bool
7485
7744
  accounting_exists_error: Exception | None
7486
- conversation_metadata: Mapping
7745
+ #: #845 §4.5: the typed result, not a bare map. An empty store and a
7746
+ #: failed read produce identical maps, and the composition of §4.6 needs to
7747
+ #: tell them apart.
7748
+ conversation_metadata: "_CodexConversationMetadataRead"
7487
7749
  #: #819: the count OR the exception, never a boolean that cannot tell them
7488
7750
  #: apart. `AccountRegistryReading` states why, and the build threads this
7489
7751
  #: one object into both the decoration gate and the published
@@ -7632,12 +7894,35 @@ def capture_codex_source_state(
7632
7894
  else:
7633
7895
  accounting_start = min(accounting_start, budget_start)
7634
7896
  with _lib_perf.phase("source.accounting"):
7635
- health = load_codex_project_metadata_health(
7636
- cache_conn=context.cache_conn,
7637
- start=accounting_start,
7638
- end=accounting_end,
7639
- )
7640
- detail_metadata_health = _probe_codex_detail_metadata_health(context)
7897
+ leg_errors: "list[tuple[str, BaseException]]" = []
7898
+ failed_legs: tuple[str, ...] = ()
7899
+ inventory = _codex_build_metadata_inventory(context.cache_conn)
7900
+ # §4.6, the first taxonomy hole. `load_codex_project_metadata_health`
7901
+ # had no local classification `try`, so a SQLite failure here
7902
+ # propagated to the broad handler below and failed the provider
7903
+ # outright. It is a leg now.
7904
+ try:
7905
+ health = load_codex_project_metadata_health(
7906
+ cache_conn=context.cache_conn,
7907
+ start=accounting_start,
7908
+ end=accounting_end,
7909
+ inventory=inventory,
7910
+ )
7911
+ except sqlite3.Error as exc:
7912
+ failed_legs = (*failed_legs, "accounting_health")
7913
+ leg_errors.append(("accounting_health", exc))
7914
+ health = CodexProjectMetadataHealth(
7915
+ total_rows=0, qualified_rows=0,
7916
+ missing_conversation_key_rows=0,
7917
+ missing_thread_join_rows=0,
7918
+ undecodable_metadata_rows=0,
7919
+ )
7920
+ detail_metadata_health, probe_error = (
7921
+ _probe_codex_detail_metadata_health_leg(
7922
+ context, inventory, accounting_start, accounting_end))
7923
+ if probe_error is not None:
7924
+ failed_legs = (*failed_legs, "detail_probe")
7925
+ leg_errors.append(("detail_probe", probe_error))
7641
7926
  accounting = _capture_codex_accounting(
7642
7927
  context,
7643
7928
  accounting_start=accounting_start,
@@ -7645,6 +7930,9 @@ def capture_codex_source_state(
7645
7930
  active_roots=active_roots,
7646
7931
  path_scope=path_scope,
7647
7932
  incomplete_rows=health.incomplete_rows,
7933
+ failed_legs=failed_legs,
7934
+ leg_errors=leg_errors,
7935
+ inventory=inventory,
7648
7936
  )
7649
7937
  try:
7650
7938
  accounting_exists = has_cached_codex_accounting_entries(
@@ -7656,8 +7944,35 @@ def capture_codex_source_state(
7656
7944
  # value, then defer it until the build knows that branch is live.
7657
7945
  accounting_exists = False
7658
7946
  accounting_exists_error = exc
7659
- conversation_metadata = MappingProxyType(
7660
- _codex_conversation_metadata(context.cache_conn))
7947
+ conversation_metadata = _codex_conversation_metadata(
7948
+ context.cache_conn,
7949
+ inventory=inventory,
7950
+ accounting_entries=accounting.entries,
7951
+ # §4.4: the identity map's trigger is the accounting-window gate,
7952
+ # not the carrier. It is consulted exactly when the qualified read
7953
+ # was skipped and the partial fold runs, and a healthy generation
7954
+ # never consults it.
7955
+ build_identity_map=accounting.metadata_incomplete,
7956
+ )
7957
+ if conversation_metadata.error is not None:
7958
+ accounting = replace(
7959
+ accounting,
7960
+ failed_legs=(*accounting.failed_legs, "conversation_metadata"),
7961
+ )
7962
+ leg_errors.append(
7963
+ ("conversation_metadata", conversation_metadata.error))
7964
+ if accounting.failed_legs:
7965
+ # ONE server-side line naming the sorted failed legs and their
7966
+ # exception classes, so a transient generation can be diagnosed
7967
+ # without guessing which read raised.
7968
+ _lib_log.get_logger("dashboard").warning(
7969
+ "Codex metadata legs failed (%s); publishing a retryable "
7970
+ "transient health result for this generation",
7971
+ ", ".join(
7972
+ f"{leg}:{type(error).__name__}"
7973
+ for leg, error in sorted(leg_errors, key=lambda pair: pair[0])
7974
+ ),
7975
+ )
7661
7976
  with _lib_perf.phase("source.accounts"):
7662
7977
  account_registry = read_account_registry(
7663
7978
  context.stats_conn, "codex")
@@ -7931,38 +8246,100 @@ def _build_codex_source_state(
7931
8246
  _warn_codex_budget_window_once("accounting_range")
7932
8247
  else:
7933
8248
  accounting_start = min(accounting_start, budget_start)
7934
- health = (
7935
- capture.health
7936
- if capture is not None
7937
- else load_codex_project_metadata_health(
7938
- cache_conn=context.cache_conn,
7939
- start=accounting_start,
7940
- end=accounting_end,
7941
- )
7942
- )
7943
- detail_metadata_health = (
7944
- capture.detail_metadata_health
7945
- if capture is not None
7946
- else _probe_codex_detail_metadata_health(context)
7947
- )
7948
- accounting_capture = (
7949
- capture.accounting
7950
- if capture is not None
7951
- else _capture_codex_accounting(
8249
+ builder_leg_errors: "list[tuple[str, BaseException]]" = []
8250
+ builder_failed_legs: tuple[str, ...] = ()
8251
+ builder_inventory: "CodexThreadMetadataInventory | None" = None
8252
+ if capture is not None:
8253
+ health = capture.health
8254
+ detail_metadata_health = capture.detail_metadata_health
8255
+ accounting_capture = capture.accounting
8256
+ else:
8257
+ builder_inventory = _codex_build_metadata_inventory(context.cache_conn)
8258
+ try:
8259
+ health = load_codex_project_metadata_health(
8260
+ cache_conn=context.cache_conn,
8261
+ start=accounting_start,
8262
+ end=accounting_end,
8263
+ inventory=builder_inventory,
8264
+ )
8265
+ except sqlite3.Error as exc:
8266
+ builder_failed_legs = (*builder_failed_legs, "accounting_health")
8267
+ builder_leg_errors.append(("accounting_health", exc))
8268
+ health = CodexProjectMetadataHealth(
8269
+ total_rows=0, qualified_rows=0,
8270
+ missing_conversation_key_rows=0, missing_thread_join_rows=0,
8271
+ undecodable_metadata_rows=0,
8272
+ )
8273
+ detail_metadata_health, builder_probe_error = (
8274
+ _probe_codex_detail_metadata_health_leg(
8275
+ context, builder_inventory, accounting_start, accounting_end))
8276
+ if builder_probe_error is not None:
8277
+ builder_failed_legs = (*builder_failed_legs, "detail_probe")
8278
+ builder_leg_errors.append(("detail_probe", builder_probe_error))
8279
+ accounting_capture = _capture_codex_accounting(
7952
8280
  context,
7953
8281
  accounting_start=accounting_start,
7954
8282
  accounting_end=accounting_end,
7955
8283
  active_roots=active_roots,
7956
8284
  path_scope=path_scope,
7957
8285
  incomplete_rows=health.incomplete_rows,
8286
+ failed_legs=builder_failed_legs,
8287
+ leg_errors=builder_leg_errors,
8288
+ inventory=builder_inventory,
7958
8289
  )
7959
- )
7960
8290
  qualified_entries = accounting_capture.qualified_entries
7961
8291
  accounting_dirty_accounts = accounting_capture.dirty_accounts
7962
8292
  accounting_changed_old = accounting_capture.changed_old
7963
8293
  accounting_changed_new = accounting_capture.changed_new
7964
8294
  accounting_entries = accounting_capture.entries
7965
8295
  metadata_incomplete = accounting_capture.metadata_incomplete
8296
+
8297
+ conversation_metadata_read = (
8298
+ capture.conversation_metadata
8299
+ if capture is not None
8300
+ else _codex_conversation_metadata(
8301
+ context.cache_conn,
8302
+ inventory=builder_inventory,
8303
+ accounting_entries=accounting_entries,
8304
+ build_identity_map=metadata_incomplete,
8305
+ )
8306
+ )
8307
+ # Restored with the typed result of §4.5: this map used to be handed
8308
+ # to the folds as a `MappingProxyType`, and the read now returns a
8309
+ # dataclass whose `metadata` member is the same dict, so the
8310
+ # read-only view moves here, where both the captured and the
8311
+ # capture-less path consume it.
8312
+ conversation_metadata = MappingProxyType(
8313
+ conversation_metadata_read.metadata)
8314
+ conversation_identity_metadata = (
8315
+ conversation_metadata_read.by_accounting_identity)
8316
+ conversation_metadata_failed = conversation_metadata_read.error is not None
8317
+ # §4.6 rule 1. A transient generation is one where ANY metadata leg failed
8318
+ # with a genuine `sqlite3.Error` or a `transient=True` refusal. It
8319
+ # publishes no Codex project rows anywhere, says so on the chip, and is
8320
+ # never retained by a reuse or retention gate, so the next tick rebuilds.
8321
+ codex_failed_legs = tuple(accounting_capture.failed_legs)
8322
+ if conversation_metadata_failed and (
8323
+ "conversation_metadata" not in codex_failed_legs
8324
+ ):
8325
+ codex_failed_legs = (*codex_failed_legs, "conversation_metadata")
8326
+ builder_leg_errors.append(
8327
+ ("conversation_metadata", conversation_metadata_read.error))
8328
+ if capture is None and codex_failed_legs:
8329
+ # The capture path emits this line itself; on the direct builder path
8330
+ # it is emitted here, once, naming the sorted failed legs and their
8331
+ # exception classes.
8332
+ _lib_log.get_logger("dashboard").warning(
8333
+ "Codex metadata legs failed (%s); publishing a retryable "
8334
+ "transient health result for this generation",
8335
+ ", ".join(
8336
+ f"{leg}:{type(error).__name__}"
8337
+ for leg, error in sorted(
8338
+ builder_leg_errors, key=lambda pair: pair[0])
8339
+ ) or ", ".join(sorted(codex_failed_legs)),
8340
+ )
8341
+ metadata_transient = bool(codex_failed_legs)
8342
+
7966
8343
  # Derived HERE rather than beside `health`, because `metadata_incomplete`
7967
8344
  # is reassigned on the line above and the message has to answer for the
7968
8345
  # state that is actually published. Computed early, a transient read
@@ -7992,7 +8369,10 @@ def _build_codex_source_state(
7992
8369
  metadata_warning_message = (
7993
8370
  "Codex project metadata could not be read for this build; it will "
7994
8371
  "retry on the next refresh."
7995
- if accounting_capture.metadata_read_failed
8372
+ if metadata_transient
8373
+ else f"{health.incomplete_rows} Codex accounting row(s) could not be "
8374
+ "used; run `cctally cache-sync --source codex --rebuild`."
8375
+ if health.malformed_accounting_rows > 0
7996
8376
  else f"{health.incomplete_rows} Codex accounting row(s) lack project "
7997
8377
  "metadata; run `cctally cache-sync --source codex --rebuild`."
7998
8378
  if health.incomplete_rows > 0
@@ -8267,14 +8647,12 @@ def _build_codex_source_state(
8267
8647
  context, budget_entries, cost_events=budget_cost_events,
8268
8648
  )
8269
8649
  configured_budget = configured_budget_domain["status"]
8270
- conversation_metadata = (
8271
- capture.conversation_metadata
8272
- if capture is not None
8273
- else _codex_conversation_metadata(context.cache_conn)
8274
- )
8275
8650
  cache_report = _codex_cache_report_wire(
8276
8651
  visible_accounting_entries,
8277
8652
  metadata=conversation_metadata,
8653
+ by_accounting_identity=(
8654
+ conversation_identity_metadata if metadata_incomplete else None),
8655
+ withhold_projects=conversation_metadata_failed,
8278
8656
  now_utc=context.now_utc,
8279
8657
  display_tz_name=context.display_tz_name,
8280
8658
  speed=context.speed,
@@ -8285,7 +8663,13 @@ def _build_codex_source_state(
8285
8663
  semantic_signature=period_signature,
8286
8664
  )
8287
8665
  projects = (
8288
- _partial_projects_wire(visible_accounting_entries, conversation_metadata)
8666
+ # §4.6 rule 1: empty on the parent and on every account child. A
8667
+ # generation whose metadata legs failed has established nothing about
8668
+ # the rows, so publishing any of them would be a guess.
8669
+ _partial_projects_wire((), {})
8670
+ if metadata_transient
8671
+ else _partial_projects_wire(
8672
+ visible_accounting_entries, conversation_identity_metadata)
8289
8673
  if metadata_incomplete else _cached_projects_wire(
8290
8674
  context,
8291
8675
  quota_observations,
@@ -8307,6 +8691,12 @@ def _build_codex_source_state(
8307
8691
  sessions,
8308
8692
  metadata=conversation_metadata,
8309
8693
  private_labels=private_session_labels,
8694
+ # §4.4: computed from the entries that built THIS view.
8695
+ unattributed_paths=(
8696
+ codex_unattributed_paths(
8697
+ visible_accounting_entries, conversation_identity_metadata)
8698
+ if metadata_incomplete else frozenset()
8699
+ ),
8310
8700
  )
8311
8701
  # #819: an unreadable account registry makes this build PARTIAL. The
8312
8702
  # source itself is intact — every non-account domain below is built from
@@ -8330,7 +8720,8 @@ def _build_codex_source_state(
8330
8720
  # would publish a scope naming two accounts beside a `data` carrying none.
8331
8721
  availability = (
8332
8722
  "partial"
8333
- if metadata_incomplete or hero_failure or account_registry.error is not None
8723
+ if metadata_incomplete or metadata_transient or hero_failure
8724
+ or account_registry.error is not None
8334
8725
  else ("ok" if (entries or quota_blocks or budget_rows) else "empty")
8335
8726
  )
8336
8727
  hero_input = None if hero_failure else sum(entry.input_tokens for entry in cycle_entries)
@@ -8339,7 +8730,7 @@ def _build_codex_source_state(
8339
8730
  hero_reasoning = None if hero_failure else sum(entry.reasoning_output_tokens for entry in cycle_entries)
8340
8731
  hero_total = None if hero_failure else sum(entry.total_tokens for entry in cycle_entries)
8341
8732
  warnings: list[SourceDashboardWarning] = []
8342
- if metadata_incomplete:
8733
+ if metadata_incomplete or metadata_transient:
8343
8734
  warnings.append(SourceDashboardWarning(
8344
8735
  "codex_metadata_incomplete",
8345
8736
  metadata_warning_message,
@@ -8499,6 +8890,9 @@ def _build_codex_source_state(
8499
8890
  accounting_end=accounting_end,
8500
8891
  metadata_incomplete=metadata_incomplete,
8501
8892
  conversation_metadata=conversation_metadata,
8893
+ conversation_identity_metadata=conversation_identity_metadata,
8894
+ conversation_metadata_failed=conversation_metadata_failed,
8895
+ metadata_transient=metadata_transient,
8502
8896
  alerts=alerts,
8503
8897
  budget_milestones=budget_rows,
8504
8898
  projected_budget_milestones=projected_budget_rows,
@@ -8878,7 +9272,7 @@ def _build_codex_source_state(
8878
9272
  # health as unknown on a store whose probe read fine.
8879
9273
  metadata_health=(
8880
9274
  build_metadata_health("transient_read_failure")
8881
- if accounting_capture.metadata_read_failed
9275
+ if metadata_transient
8882
9276
  else detail_metadata_health
8883
9277
  ),
8884
9278
  )