cctally 1.68.0 → 1.69.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 (40) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +168 -5
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +202 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +324 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_lib_share.py CHANGED
@@ -14,6 +14,7 @@ import re
14
14
  from collections.abc import Callable, Mapping
15
15
  from dataclasses import dataclass
16
16
  from datetime import datetime
17
+ from typing import Literal
17
18
 
18
19
 
19
20
  # --- Version + digest ---
@@ -74,6 +75,7 @@ class ProjectCell:
74
75
  MoneyCells (back-compat for every non-budget construction site)."""
75
76
  label: str
76
77
  rank_cost: float | None = None
78
+ identity: str | None = None
77
79
 
78
80
  Cell = TextCell | MoneyCell | PercentCell | DateCell | DeltaCell | ProjectCell
79
81
 
@@ -87,6 +89,7 @@ class ColumnSpec:
87
89
  align: str = "left" # "left" | "right" | "center"
88
90
  emphasis: bool = False
89
91
  kind: str | None = None # "project" | "model" | None — privacy chokepoint signal
92
+ project_identity: str | None = None
90
93
 
91
94
 
92
95
  @dataclass(frozen=True)
@@ -117,6 +120,7 @@ class ChartPoint:
117
120
  y_value: float
118
121
  project_label: str | None = None
119
122
  series_key: str | None = None
123
+ project_identity: str | None = None
120
124
 
121
125
 
122
126
  @dataclass(frozen=True)
@@ -179,6 +183,21 @@ class ShareSnapshot:
179
183
  generated_at: datetime
180
184
  version: str
181
185
  template_id: str | None = None
186
+ source: Literal["claude", "codex"] = "claude"
187
+ source_label: str | None = None
188
+ availability: Literal["ok", "empty", "unavailable"] = "ok"
189
+ availability_reason: str | None = None
190
+
191
+ def __post_init__(self) -> None:
192
+ if self.source not in {"claude", "codex"}:
193
+ raise ValueError("share source must be claude or codex")
194
+ if self.availability not in {"ok", "empty", "unavailable"}:
195
+ raise ValueError("share availability must be ok, empty, or unavailable")
196
+ if self.availability == "unavailable":
197
+ if not self.availability_reason:
198
+ raise ValueError("unavailable share snapshots require a reason")
199
+ elif self.availability_reason is not None:
200
+ raise ValueError("availability reason is only valid for unavailable snapshots")
182
201
 
183
202
 
184
203
  # --- Compose: multi-section stitching (M3.1) ---
@@ -720,12 +739,25 @@ def _render_svg_header(snap: ShareSnapshot, *, palette: dict,
720
739
  if snap.subtitle:
721
740
  elements.append(svg_text(x, y + 36, snap.subtitle,
722
741
  font_size=12, fill=palette["muted"]))
742
+ source_label, availability = _source_chrome(snap)
743
+ if source_label:
744
+ source_text = source_label if availability is None else f"{source_label} · {availability}"
745
+ elements.append(svg_text(x, y + 54, source_text,
746
+ font_size=10, fill=palette["muted"]))
723
747
  elements.append(svg_text(x + width, y + 18,
724
748
  _format_generated_at_iso(snap.generated_at),
725
749
  font_size=10, fill=palette["muted"], anchor="end"))
726
750
  return svg_group(elements)
727
751
 
728
752
 
753
+ def _svg_header_height(snap: ShareSnapshot, *, include_chrome: bool) -> float:
754
+ """Reserve the added source/availability line without perturbing v1 SVGs."""
755
+ if not include_chrome:
756
+ return 0.0
757
+ source_label, _availability = _source_chrome(snap)
758
+ return _SVG_HEADER_H + (18.0 if source_label else 0.0)
759
+
760
+
729
761
  def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
730
762
  x: float, y: float, width: float, branding: bool) -> str:
731
763
  if not branding:
@@ -739,6 +771,26 @@ def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
739
771
  ])
740
772
 
741
773
 
774
+ def _source_chrome(snap: ShareSnapshot) -> tuple[str | None, str | None]:
775
+ """Return privacy-safe provider and availability text for new snapshots.
776
+
777
+ The all-default Claude shape deliberately returns no provider line so every
778
+ existing share artifact remains byte-for-byte unchanged.
779
+ """
780
+ show_source = (
781
+ snap.source != "claude"
782
+ or snap.source_label is not None
783
+ or snap.availability != "ok"
784
+ )
785
+ label = snap.source_label or ("Claude" if snap.source == "claude" else "Codex")
786
+ status = (
787
+ "No data" if snap.availability == "empty" else
788
+ f"Unavailable: {snap.availability_reason}"
789
+ if snap.availability == "unavailable" else None
790
+ )
791
+ return (label if show_source else None, status)
792
+
793
+
742
794
  # --- Scrubber ---
743
795
  #
744
796
  # Anonymization chokepoint (spec Section 5.3 / 7 / 8.4). Operates on a
@@ -751,7 +803,15 @@ def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
751
803
  # costs` (gather) and `_apply_anon_mapping` (rewrite) must be extended.
752
804
 
753
805
 
754
- def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
806
+ _ProjectAnonKey = tuple[Literal["legacy", "qualified"], str]
807
+
808
+
809
+ def _project_anon_key(label: str, identity: str | None) -> _ProjectAnonKey:
810
+ """Keep opaque qualified keys disjoint from legacy display labels."""
811
+ return ("qualified", identity) if identity is not None else ("legacy", label)
812
+
813
+
814
+ def _collect_project_identity_costs(snap: ShareSnapshot) -> dict[_ProjectAnonKey, float]:
755
815
  """Walk rows: for each row containing a ProjectCell, sum MoneyCell values
756
816
  in the same row under the project label — unless the ProjectCell carries an
757
817
  explicit ``rank_cost``, which takes precedence over the MoneyCell sum (#130).
@@ -759,20 +819,19 @@ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
759
819
  Charts also contribute via ChartPoint.project_label + y_value (when y_value
760
820
  is in $). For consistency we union both sources; rows take precedence on
761
821
  duplicates."""
762
- costs: dict[str, float] = {}
822
+ costs: dict[_ProjectAnonKey, float] = {}
763
823
  for row in snap.rows:
764
- proj_label: str | None = None
824
+ project: ProjectCell | None = None
765
825
  money = 0.0
766
- explicit_rank: float | None = None
767
826
  for cell in row.cells.values():
768
827
  if isinstance(cell, ProjectCell):
769
- proj_label = cell.label
770
- explicit_rank = cell.rank_cost
828
+ project = cell
771
829
  elif isinstance(cell, MoneyCell):
772
830
  money += cell.usd
773
- if proj_label is not None:
774
- contribution = explicit_rank if explicit_rank is not None else money
775
- costs[proj_label] = costs.get(proj_label, 0.0) + contribution
831
+ if project is not None:
832
+ key = _project_anon_key(project.label, project.identity)
833
+ contribution = project.rank_cost if project.rank_cost is not None else money
834
+ costs[key] = costs.get(key, 0.0) + contribution
776
835
 
777
836
  if snap.chart is not None:
778
837
  chart_pts: list[ChartPoint] = []
@@ -792,8 +851,10 @@ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
792
851
  # bar charts but may be a ratio for trend charts. Affects sort order of
793
852
  # project-N labels, not anonymization correctness.
794
853
  for p in chart_pts:
795
- if p.project_label and p.project_label not in costs:
796
- costs[p.project_label] = p.y_value
854
+ if p.project_label:
855
+ key = _project_anon_key(p.project_label, p.project_identity)
856
+ if key not in costs:
857
+ costs[key] = p.y_value
797
858
 
798
859
  # project-typed columns (cross-tab Detail templates, issue #33). Sum the
799
860
  # MoneyCell values for each kind='project' column across all rows; the
@@ -809,32 +870,58 @@ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
809
870
  cell = row.cells.get(col.key)
810
871
  if isinstance(cell, MoneyCell):
811
872
  col_total += cell.usd
812
- costs[col.label] = costs.get(col.label, 0.0) + col_total
873
+ key = _project_anon_key(col.label, col.project_identity)
874
+ costs[key] = costs.get(key, 0.0) + col_total
875
+
876
+ return costs
813
877
 
878
+
879
+ def _collect_project_costs(
880
+ snap: ShareSnapshot,
881
+ ) -> dict[_ProjectAnonKey, float] | dict[str, float]:
882
+ """Return the historical legacy shape unless qualified identity is present.
883
+
884
+ The scrubber consumes :func:`_collect_project_identity_costs` directly so
885
+ its internal keys remain domain tagged. This compatibility facade keeps
886
+ callers of the older all-``None`` model on their established string-key
887
+ contract.
888
+ """
889
+ costs = _collect_project_identity_costs(snap)
890
+ if all(domain == "legacy" for domain, _value in costs):
891
+ return {label: cost for (_domain, label), cost in costs.items()}
814
892
  return costs
815
893
 
816
894
 
817
- def _build_anon_mapping(project_costs: dict[str, float]) -> dict[str, str]:
818
- """Sort labels by descending cost (lex tie-break); assign project-1, project-2, ...
895
+ def _build_anon_mapping(
896
+ project_costs: dict[_ProjectAnonKey, float] | dict[str, float],
897
+ ) -> dict[_ProjectAnonKey, str] | dict[str, str]:
898
+ """Sort identities by descending cost (lex tie-break); assign project-1, project-2, ...
819
899
 
820
900
  "(unknown)" is never numbered — keeps its literal label.
821
901
  """
902
+ legacy_input = all(isinstance(key, str) for key in project_costs)
903
+ normalized: dict[_ProjectAnonKey, float] = {
904
+ (("legacy", key) if isinstance(key, str) else key): cost
905
+ for key, cost in project_costs.items()
906
+ }
822
907
  items = [
823
- (label, cost)
824
- for label, cost in project_costs.items()
825
- if label != "(unknown)"
908
+ (key, cost)
909
+ for key, cost in normalized.items()
910
+ if key != ("legacy", "(unknown)")
826
911
  ]
827
912
  items.sort(key=lambda kv: (-kv[1], kv[0]))
828
- mapping: dict[str, str] = {
829
- label: f"project-{i + 1}" for i, (label, _cost) in enumerate(items)
913
+ mapping: dict[_ProjectAnonKey, str] = {
914
+ key: f"project-{i + 1}" for i, (key, _cost) in enumerate(items)
830
915
  }
831
- if "(unknown)" in project_costs:
832
- mapping["(unknown)"] = "(unknown)"
916
+ if ("legacy", "(unknown)") in normalized:
917
+ mapping[("legacy", "(unknown)")] = "(unknown)"
918
+ if legacy_input:
919
+ return {key[1]: value for key, value in mapping.items()}
833
920
  return mapping
834
921
 
835
922
 
836
923
  def _apply_anon_mapping(
837
- snap: ShareSnapshot, mapping: dict[str, str],
924
+ snap: ShareSnapshot, mapping: dict[_ProjectAnonKey, str] | dict[str, str],
838
925
  ) -> ShareSnapshot:
839
926
  """Return a new ShareSnapshot with project labels replaced everywhere.
840
927
 
@@ -842,13 +929,20 @@ def _apply_anon_mapping(
842
929
  (b) ChartPoint.project_label AND .x_label (when x_label == project_label,
843
930
  i.e. project-axis charts) on chart.points + multi_series + stacks.
844
931
  """
932
+ tagged_mapping: dict[_ProjectAnonKey, str] = {
933
+ ("legacy", key) if isinstance(key, str) else key: value
934
+ for key, value in mapping.items()
935
+ }
845
936
  new_rows: list[Row] = []
846
937
  for row in snap.rows:
847
938
  new_cells: dict[str, Cell] = {}
848
939
  for key, cell in row.cells.items():
849
- if isinstance(cell, ProjectCell) and cell.label in mapping:
940
+ if isinstance(cell, ProjectCell):
941
+ project_key = _project_anon_key(cell.label, cell.identity)
850
942
  new_cells[key] = ProjectCell(
851
- mapping[cell.label], rank_cost=cell.rank_cost
943
+ tagged_mapping.get(project_key, "(unknown)"),
944
+ rank_cost=cell.rank_cost,
945
+ identity=cell.identity,
852
946
  )
853
947
  else:
854
948
  new_cells[key] = cell
@@ -863,7 +957,8 @@ def _apply_anon_mapping(
863
957
  # points after gather) is mapped to "(unknown)" rather than
864
958
  # passed through. Privacy invariant: never leak a non-anonymized
865
959
  # label, even if the gather pass missed it.
866
- new_label = mapping.get(p.project_label, "(unknown)")
960
+ project_key = _project_anon_key(p.project_label, p.project_identity)
961
+ new_label = tagged_mapping.get(project_key, "(unknown)")
867
962
  else:
868
963
  new_label = None
869
964
  # x_label rewrite stays guarded — only anonymize if x_label is the
@@ -871,8 +966,8 @@ def _apply_anon_mapping(
871
966
  # x_label values like time labels).
872
967
  if (p.project_label
873
968
  and p.x_label == p.project_label
874
- and p.x_label in mapping):
875
- new_x = mapping[p.x_label]
969
+ and project_key in tagged_mapping):
970
+ new_x = tagged_mapping[project_key]
876
971
  else:
877
972
  new_x = p.x_label
878
973
  return ChartPoint(
@@ -881,6 +976,7 @@ def _apply_anon_mapping(
881
976
  y_value=p.y_value,
882
977
  project_label=new_label,
883
978
  series_key=p.series_key,
979
+ project_identity=p.project_identity,
884
980
  )
885
981
 
886
982
  if isinstance(snap.chart, LineChart):
@@ -918,10 +1014,13 @@ def _apply_anon_mapping(
918
1014
  new_columns: list[ColumnSpec] = []
919
1015
  for col in snap.columns:
920
1016
  if col.kind == "project":
921
- new_label = mapping.get(col.label, "(unknown)")
1017
+ new_label = tagged_mapping.get(
1018
+ _project_anon_key(col.label, col.project_identity), "(unknown)",
1019
+ )
922
1020
  new_columns.append(ColumnSpec(
923
1021
  key=col.key, label=new_label,
924
1022
  align=col.align, emphasis=col.emphasis, kind=col.kind,
1023
+ project_identity=col.project_identity,
925
1024
  ))
926
1025
  else:
927
1026
  new_columns.append(col)
@@ -941,6 +1040,10 @@ def _apply_anon_mapping(
941
1040
  generated_at=snap.generated_at,
942
1041
  version=snap.version,
943
1042
  template_id=snap.template_id,
1043
+ source=snap.source,
1044
+ source_label=snap.source_label,
1045
+ availability=snap.availability,
1046
+ availability_reason=snap.availability_reason,
944
1047
  )
945
1048
 
946
1049
 
@@ -955,7 +1058,7 @@ def _scrub(snap: ShareSnapshot, *, reveal_projects: bool) -> ShareSnapshot:
955
1058
  """
956
1059
  if reveal_projects:
957
1060
  return snap
958
- project_costs = _collect_project_costs(snap)
1061
+ project_costs = _collect_project_identity_costs(snap)
959
1062
  if not project_costs:
960
1063
  return snap
961
1064
  mapping = _build_anon_mapping(project_costs)
@@ -974,6 +1077,11 @@ def _render_md_fragment(snap: ShareSnapshot, *, branding: bool) -> str:
974
1077
  so future surfaces (compose, history) extend it once.
975
1078
  """
976
1079
  parts = [f"# {_md_escape(snap.title)}"]
1080
+ source_label, availability = _source_chrome(snap)
1081
+ if source_label:
1082
+ parts.extend(["", f"**{_md_escape(source_label)}**"])
1083
+ if availability:
1084
+ parts.extend(["", _md_escape(availability)])
977
1085
  if snap.subtitle:
978
1086
  parts.append(f"_{_md_escape(snap.subtitle)}_")
979
1087
  parts.append(f"_{_format_generated_at_iso(snap.generated_at)}_")
@@ -1277,10 +1385,11 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
1277
1385
  """
1278
1386
  has_table = include_table and bool(snap.columns)
1279
1387
  chart_h = _SVG_CHART_H if snap.chart is not None else 0
1388
+ header_h = _svg_header_height(snap, include_chrome=include_chrome)
1280
1389
 
1281
1390
  # Pre-layout the table (we need its height before declaring outer SVG height).
1282
1391
  if has_table:
1283
- table_y = _SVG_PADDING + (_SVG_HEADER_H if include_chrome else 0) + chart_h + _SVG_TABLE_GAP
1392
+ table_y = _SVG_PADDING + header_h + chart_h + _SVG_TABLE_GAP
1284
1393
  table_svg, table_h, table_w = _render_svg_table(
1285
1394
  snap, palette=palette, x=_SVG_PADDING, y=table_y, max_width=_SVG_WIDTH,
1286
1395
  )
@@ -1297,7 +1406,7 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
1297
1406
  table_block_h = (_SVG_TABLE_GAP + table_h) if has_table else 0
1298
1407
 
1299
1408
  if include_chrome:
1300
- height = _SVG_HEADER_H + chart_h + table_block_h + _SVG_FOOTER_H + (_SVG_PADDING * 2)
1409
+ height = header_h + chart_h + table_block_h + _SVG_FOOTER_H + (_SVG_PADDING * 2)
1301
1410
  else:
1302
1411
  height = chart_h + table_block_h + (_SVG_PADDING * 2)
1303
1412
 
@@ -1309,7 +1418,7 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
1309
1418
  x=_SVG_PADDING, y=_SVG_PADDING, width=_SVG_WIDTH,
1310
1419
  ))
1311
1420
 
1312
- chart_y = _SVG_PADDING + (_SVG_HEADER_H if include_chrome else 0)
1421
+ chart_y = _SVG_PADDING + header_h
1313
1422
  if snap.chart is not None:
1314
1423
  if isinstance(snap.chart, LineChart):
1315
1424
  pieces.append(_render_line_chart_svg(
@@ -1331,7 +1440,7 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
1331
1440
  pieces.append(table_svg)
1332
1441
 
1333
1442
  if include_chrome:
1334
- footer_y = _SVG_PADDING + _SVG_HEADER_H + chart_h + table_block_h + _SVG_FOOTER_BASELINE
1443
+ footer_y = _SVG_PADDING + header_h + chart_h + table_block_h + _SVG_FOOTER_BASELINE
1335
1444
  pieces.append(_render_svg_footer(
1336
1445
  snap, palette=palette,
1337
1446
  x=_SVG_PADDING, y=footer_y,
@@ -1429,6 +1538,15 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
1429
1538
  if snap.chart is not None else ""
1430
1539
  )
1431
1540
  title_html = f'<h1 style="font-size:20px;color:{palette["fg"]};margin:0">{_xml_escape(snap.title)}</h1>'
1541
+ source_label, availability = _source_chrome(snap)
1542
+ source_html = (
1543
+ f'<div style="font-size:13px;color:{palette["muted"]};margin-top:4px">{_xml_escape(source_label)}</div>'
1544
+ if source_label else ""
1545
+ )
1546
+ availability_html = (
1547
+ f'<div style="font-size:13px;color:{palette["fg"]};margin-top:4px">{_xml_escape(availability)}</div>'
1548
+ if availability else ""
1549
+ )
1432
1550
  subtitle_html = (
1433
1551
  f'<div style="font-size:13px;color:{palette["muted"]};margin-top:4px">{_xml_escape(snap.subtitle)}</div>'
1434
1552
  if snap.subtitle else ""
@@ -1452,7 +1570,7 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
1452
1570
  else:
1453
1571
  footer_html = ""
1454
1572
  return (
1455
- f'<header>{title_html}{subtitle_html}{timestamp_html}</header>'
1573
+ f'<header>{title_html}{source_html}{availability_html}{subtitle_html}{timestamp_html}</header>'
1456
1574
  f'{chart_html}'
1457
1575
  f'{table_html}'
1458
1576
  f'{footer_html}'
@@ -85,6 +85,12 @@ class SnapshotSignature(NamedTuple):
85
85
  # finalization UPSERT advances this leg while `max_entry_id` stays flat, so
86
86
  # the dashboard leaves the idle path and recomputes the affected bucket.
87
87
  entry_mutation_seq: int = 0
88
+ # #294 S4: Codex metadata, root, quota, and destructive mutations can
89
+ # leave `MAX(codex_session_entries.id)` flat. The cache-local physical
90
+ # sequence supplies that missing identity leg; the stats digest arrives
91
+ # from the independently-committed quota/budget projection database.
92
+ codex_physical_mutation_seq: int = 0
93
+ codex_stats_digest: str = ""
88
94
 
89
95
 
90
96
  def _max_id(conn: sqlite3.Connection, table: str) -> int:
@@ -127,6 +133,23 @@ def _entry_mutation_seq(conn: sqlite3.Connection) -> int:
127
133
  return int(row[0])
128
134
 
129
135
 
136
+ def _codex_physical_mutation_seq(conn: sqlite3.Connection) -> int:
137
+ """Read the O(1) Codex physical mutation sequence, degrading to zero."""
138
+ try:
139
+ row = conn.execute(
140
+ "SELECT value FROM cache_meta "
141
+ "WHERE key='codex_physical_mutation_seq'"
142
+ ).fetchone()
143
+ except sqlite3.Error:
144
+ return 0
145
+ if row is None or row[0] is None:
146
+ return 0
147
+ try:
148
+ return int(row[0])
149
+ except (TypeError, ValueError):
150
+ return 0
151
+
152
+
130
153
  def _reset_sig(conn: sqlite3.Connection) -> tuple[int, int]:
131
154
  """Change-signal over the two reset-event tables combined (spec §3).
132
155
 
@@ -155,6 +178,7 @@ def compute_signature(
155
178
  stats_conn: sqlite3.Connection,
156
179
  *,
157
180
  generation: int,
181
+ codex_stats_digest: str = "",
158
182
  ) -> SnapshotSignature:
159
183
  """Composite data-version signature across cache.db + stats.db (spec §3).
160
184
 
@@ -173,6 +197,8 @@ def compute_signature(
173
197
  max_codex_id=_max_id(cache_conn, "codex_session_entries"),
174
198
  generation=int(generation),
175
199
  entry_mutation_seq=_entry_mutation_seq(cache_conn),
200
+ codex_physical_mutation_seq=_codex_physical_mutation_seq(cache_conn),
201
+ codex_stats_digest=str(codex_stats_digest),
176
202
  )
177
203
 
178
204
 
@@ -0,0 +1,74 @@
1
+ """Pure, opaque provider-qualified identity helpers for #294 S0/S1."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import re
8
+
9
+
10
+ IDENTITY_VERSION = 1
11
+ _SOURCES = frozenset(("claude", "codex"))
12
+ _SOURCE_ROOT_KEY_RE = re.compile(r"[0-9a-f]{32}\Z")
13
+
14
+
15
+ def _required_string(value: object, name: str) -> str:
16
+ if not isinstance(value, str) or not value:
17
+ raise ValueError(f"{name} must be a non-empty string")
18
+ return value
19
+
20
+
21
+ def source_root_key(canonical_root: str) -> str:
22
+ """Return the non-reversible, domain-separated key for one source root."""
23
+ root = _required_string(canonical_root, "canonical_root")
24
+ digest = hashlib.sha256(b"cctally-source-root-v1\0" + root.encode("utf-8"))
25
+ return digest.hexdigest()[:32]
26
+
27
+
28
+ def canonical_identity(
29
+ source: str,
30
+ resource_kind: str,
31
+ source_root: str | None,
32
+ native_key: str,
33
+ parent_key: str | None,
34
+ ) -> str:
35
+ """Encode an opaque IdentityV1 after deriving an optional root key."""
36
+ root_key = None
37
+ if source_root is not None:
38
+ root_key = source_root_key(source_root)
39
+ return canonical_identity_from_root_key(
40
+ source, resource_kind, root_key, native_key, parent_key
41
+ )
42
+
43
+
44
+ def canonical_identity_from_root_key(
45
+ source: str,
46
+ resource_kind: str,
47
+ source_root_key: str | None,
48
+ native_key: str,
49
+ parent_key: str | None,
50
+ ) -> str:
51
+ """Encode an opaque IdentityV1 from an already-derived source-root key."""
52
+ if source not in _SOURCES:
53
+ raise ValueError(f"source must be one of {sorted(_SOURCES)}")
54
+ kind = _required_string(resource_kind, "resource_kind")
55
+ native = _required_string(native_key, "native_key")
56
+ parent = None if parent_key is None else _required_string(parent_key, "parent_key")
57
+ if source_root_key is not None:
58
+ root_key = _required_string(source_root_key, "source_root_key")
59
+ if not _SOURCE_ROOT_KEY_RE.fullmatch(root_key):
60
+ raise ValueError("source_root_key must be a 32-character lowercase hex key")
61
+ else:
62
+ root_key = None
63
+ payload = {
64
+ "nativeKey": native,
65
+ "parentKey": parent,
66
+ "resourceKind": kind,
67
+ "source": source,
68
+ "sourceRootKey": root_key,
69
+ "version": IDENTITY_VERSION,
70
+ }
71
+ canonical = json.dumps(
72
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
73
+ ).encode("utf-8")
74
+ return "v1." + base64.urlsafe_b64encode(canonical).decode("ascii").rstrip("=")