cctally 1.92.2 → 1.93.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 (36) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/_cctally_cache.py +354 -0
  3. package/bin/_cctally_core.py +180 -3
  4. package/bin/_cctally_dashboard.py +71 -1
  5. package/bin/_cctally_dashboard_envelope.py +28 -2
  6. package/bin/_cctally_dashboard_share.py +75 -19
  7. package/bin/_cctally_dashboard_sources.py +12 -0
  8. package/bin/_cctally_db.py +89 -1
  9. package/bin/_cctally_doctor.py +31 -0
  10. package/bin/_cctally_forecast.py +4 -2
  11. package/bin/_cctally_journal.py +3482 -258
  12. package/bin/_cctally_journal_repair.py +123 -32
  13. package/bin/_cctally_milestone_history.py +4 -1
  14. package/bin/_cctally_project.py +8 -6
  15. package/bin/_cctally_quota.py +420 -20
  16. package/bin/_cctally_rederive.py +57 -23
  17. package/bin/_cctally_reporting.py +8 -6
  18. package/bin/_cctally_share.py +74 -37
  19. package/bin/_cctally_source_analytics.py +6 -8
  20. package/bin/_cctally_store.py +13 -2
  21. package/bin/_cctally_tui.py +53 -0
  22. package/bin/_lib_cache_coverage.py +547 -0
  23. package/bin/_lib_doctor.py +54 -2
  24. package/bin/_lib_journal.py +235 -95
  25. package/bin/_lib_journal_router.py +21 -0
  26. package/bin/_lib_segment_summary.py +374 -0
  27. package/bin/_lib_selector_state.py +959 -0
  28. package/bin/_lib_share.py +1073 -165
  29. package/bin/_lib_share_templates.py +35 -11
  30. package/bin/_lib_stats_wal.py +327 -0
  31. package/bin/_lib_view_models.py +2 -1
  32. package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
  33. package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +5 -1
  36. package/dashboard/static/assets/index-DnWdv8um.css +0 -1
package/bin/_lib_share.py CHANGED
@@ -7,12 +7,14 @@ Spec: docs/superpowers/specs/2026-05-08-shareable-reports-design.md
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ import base64
11
+ import dataclasses
10
12
  import hashlib
11
13
  import json
12
14
  import math
13
15
  import re
14
- from collections.abc import Callable, Mapping
15
- from dataclasses import dataclass
16
+ from collections.abc import Callable, Mapping, Sequence
17
+ from dataclasses import dataclass, field
16
18
  from datetime import datetime
17
19
  from typing import Literal
18
20
 
@@ -115,12 +117,23 @@ class PeriodSpec:
115
117
 
116
118
  @dataclass(frozen=True)
117
119
  class ChartPoint:
120
+ """One chart datum.
121
+
122
+ `x_label_kind` is the AXIS DISCRIMINATOR. Privacy preparation rewrites the
123
+ `x_label` of a `"project"` axis and leaves a `"plain"` axis untouched; it
124
+ must never infer the axis from `x_label == project_label`, because the two
125
+ `sessions` builders deliberately break that equality (issue #503 F1).
126
+ A `"project"` axis composes its label from the resolved project display
127
+ name, prefixed by `x_label_prefix` (the cost rank) when one is present.
128
+ """
118
129
  x_label: str
119
130
  x_value: float
120
131
  y_value: float
121
132
  project_label: str | None = None
122
133
  series_key: str | None = None
123
134
  project_identity: str | None = None
135
+ x_label_kind: Literal["plain", "project"] = "plain"
136
+ x_label_prefix: str | None = None
124
137
 
125
138
 
126
139
  @dataclass(frozen=True)
@@ -235,8 +248,8 @@ class ComposeOptions:
235
248
  theme: str # "light" | "dark"
236
249
  format: str # "md" | "html" | "svg"
237
250
  no_branding: bool
238
- # kernel: informational only — actual scrub happens upstream in
239
- # the API layer before sections reach `compose()`.
251
+ # `compose()` reads this to prepare every section itself (#503 S1).
252
+ # Sections must arrive RAW; callers must not anonymize upstream.
240
253
  reveal_projects: bool
241
254
 
242
255
 
@@ -950,22 +963,411 @@ def _apply_anon_mapping(
950
963
  ) -> ShareSnapshot:
951
964
  """Return a new ShareSnapshot with project labels replaced everywhere.
952
965
 
953
- Walks: (a) every Row.cells dict — replaces ProjectCell.label;
954
- (b) ChartPoint.project_label AND .x_label (when x_label == project_label,
955
- i.e. project-axis charts) on chart.points + multi_series + stacks.
966
+ Kept for backward compatibility as `_scrub`'s applier. It normalizes the
967
+ legacy all-string key shape and then delegates to the single project-field
968
+ walker `_apply_project_mapping`, so there is exactly one implementation of
969
+ "rewrite every typed project display field" rather than two that have to
970
+ agree by inspection — which is how the `x_label` fall-through survived.
956
971
  """
957
972
  tagged_mapping: dict[_ProjectAnonKey, str] = {
958
973
  ("legacy", key) if isinstance(key, str) else key: value
959
974
  for key, value in mapping.items()
960
975
  }
976
+ return _apply_project_mapping(snap, tagged_mapping)
977
+
978
+
979
+ def _scrub(snap: ShareSnapshot, *, reveal_projects: bool) -> ShareSnapshot:
980
+ """Anonymize project labels unless reveal_projects is True.
981
+
982
+ When reveal_projects is True, returns the SAME instance (identity preserved
983
+ so callers can rely on `out is snap`). When False, returns a NEW snapshot
984
+ with ProjectCell labels and ChartPoint project/x labels rewritten via
985
+ `_build_anon_mapping`. If no project labels are present in the snapshot,
986
+ also returns the original instance.
987
+ """
988
+ if reveal_projects:
989
+ return snap
990
+ project_costs = _collect_project_identity_costs(snap)
991
+ if not project_costs:
992
+ return snap
993
+ mapping = _build_anon_mapping(project_costs)
994
+ return _apply_anon_mapping(snap, mapping)
995
+
996
+
997
+ # --- Preparation: the privacy contract (#503 S1) ---
998
+ #
999
+ # `_scrub` above is a hand-enumerated field walker: it visits three sites out
1000
+ # of a seventeen-field frozen dataclass graph and copies everything else
1001
+ # through untouched. A value leaks whenever a builder places it somewhere the
1002
+ # enumeration does not reach. Preparation replaces it as the path the entry
1003
+ # points use. `_scrub` stays public and identity-preserving for backward
1004
+ # compatibility; it is simply no longer how `render()` and `compose()` get
1005
+ # their anonymization.
1006
+ #
1007
+ # Preparation is stage 2 of the four-stage contract the entry points run:
1008
+ # inventory -> prepare -> render -> verify. It rewrites ONLY typed project
1009
+ # display fields, so a builder that puts a path into `title`, `notes` or
1010
+ # `totals` is caught by stage 4 and raises rather than being silently
1011
+ # corrected. That is intended: silent correction hides the builder defect.
1012
+
1013
+ ANON_UNKNOWN = "(unknown)"
1014
+
1015
+ _PREPARED_ATTR = "_share_prepared_provenance"
1016
+
1017
+
1018
+ class SharePreparationError(Exception):
1019
+ """Raised when a snapshot reaches an entry point already prepared.
1020
+
1021
+ A second preparation pass is not idempotent. On the legacy path — where
1022
+ `ProjectCell.identity` is None — the alias key of an already-aliased label
1023
+ becomes ``("legacy", "project-1")`` and a second pass RENUMBERS by
1024
+ re-ranking. In compose it is worse: two distinct raw projects that each
1025
+ mapped locally to ``project-1`` collapse into one legacy key before global
1026
+ ranking, merging two projects into a single alias.
1027
+
1028
+ An alias-shape assertion is deliberately NOT the discriminator here — a
1029
+ real project can legitimately be named ``project-1`` — so preparation
1030
+ stamps an explicit provenance marker instead.
1031
+ """
1032
+
1033
+
1034
+ @dataclass(frozen=True)
1035
+ class _PreparedProvenance:
1036
+ """What preparation did, recorded for the verification stage.
1037
+
1038
+ `originals` are the project display labels preparation consumed;
1039
+ `allowed` are the values it is permitted to have emitted in their place.
1040
+ Verification's provenance half checks the emitted values against
1041
+ `allowed` rather than searching the document for `originals`, because a
1042
+ project legitimately named `cctally` collides with the static branding
1043
+ string this module emits and a text search would fail a correctly
1044
+ anonymized artifact.
1045
+ """
1046
+ reveal_projects: bool
1047
+ originals: frozenset[str]
1048
+ allowed: frozenset[str]
1049
+
1050
+
1051
+ def _is_prepared(snap: ShareSnapshot) -> bool:
1052
+ """True when `_prepare` produced this snapshot object."""
1053
+ return isinstance(getattr(snap, _PREPARED_ATTR, None), _PreparedProvenance)
1054
+
1055
+
1056
+ def _provenance_of(snap: ShareSnapshot) -> "_PreparedProvenance | None":
1057
+ value = getattr(snap, _PREPARED_ATTR, None)
1058
+ return value if isinstance(value, _PreparedProvenance) else None
1059
+
1060
+
1061
+ # --- Reveal-mode display labels ---
1062
+
1063
+ def _path_segments(path: str) -> list[str]:
1064
+ return [seg for seg in path.split("/") if seg]
1065
+
1066
+
1067
+ def disambiguate_basenames(paths: Sequence[str]) -> dict[int, str]:
1068
+ """Return ``{index: displayed label}`` for a list of project paths.
1069
+
1070
+ INPUT CONTRACT: `paths` is one entry per DISTINCT project identity.
1071
+ Callers holding several rows of the same project must deduplicate first
1072
+ and fan the returned label back out themselves. This function is total —
1073
+ given two identical paths it cannot tell them apart, so it appends a
1074
+ stable ordinal to keep the mapping injective — and that ordinal is wrong
1075
+ output for duplicates of one project: it displays one project under
1076
+ several names, and on the legacy path (where `ProjectCell.identity` is
1077
+ None and the alias key is the label) it also splits that project's cost
1078
+ across several alias slots. Every in-tree caller honors the contract:
1079
+ `_resolved_project_labels` and `_merged_project_mapping` pass one entry
1080
+ per `_ProjectAnonKey`, and `_cctally_share._session_disambiguate_labels`
1081
+ deduplicates by path.
1082
+
1083
+ Reveal mode shows a project's basename, never its full path. Bare
1084
+ ``os.path.basename`` is NOT sufficient and using it would reintroduce a
1085
+ defect the CLI already solved: two ``app`` projects under different
1086
+ parents collapse into one indistinguishable label, and after
1087
+ anonymization into ONE ``project-N`` alias — losing both privacy
1088
+ uniqueness and chart-rank meaning. See the comment at
1089
+ ``bin/_cctally_share.py`` above ``_project_disambiguate_labels``'s call
1090
+ site, which records that reasoning.
1091
+
1092
+ The algorithm is deterministic: basename; on collision a parent-directory
1093
+ suffix ``" (parent)"``; on a repeated parent, progressively more path
1094
+ segments; and a stable ordinal as the last resort when the paths
1095
+ themselves are indistinguishable. A label with no separator (already a
1096
+ basename, or an already-disambiguated ``app (work)`` from the CLI) is its
1097
+ own basename and passes through untouched.
1098
+
1099
+ ``(unknown)`` is never suffixed: ``_build_anon_mapping`` protects only the
1100
+ exact literal, so a suffixed ``(unknown) (/)`` would be numbered like an
1101
+ ordinary project and lose the sentinel's meaning.
1102
+ """
1103
+ segs = [_path_segments(p or "") for p in paths]
1104
+ bases: list[str] = []
1105
+ for i, p in enumerate(paths):
1106
+ bases.append(segs[i][-1] if segs[i] else ((p or "") or ANON_UNKNOWN))
1107
+ labels: dict[int, str] = dict(enumerate(bases))
1108
+ max_depth = max((len(s) for s in segs), default=1)
1109
+ depth = 1
1110
+ while True:
1111
+ groups: dict[str, list[int]] = {}
1112
+ for idx, lab in labels.items():
1113
+ groups.setdefault(lab, []).append(idx)
1114
+ collided = [
1115
+ idxs for lab, idxs in groups.items()
1116
+ if len(idxs) > 1 and lab != ANON_UNKNOWN
1117
+ ]
1118
+ if not collided:
1119
+ return labels
1120
+ if depth > max_depth:
1121
+ # Indistinguishable inputs (identical paths, or paths that differ
1122
+ # only past every segment we can show). Stay total and stay
1123
+ # deterministic rather than emitting duplicates.
1124
+ for idxs in collided:
1125
+ for rank, idx in enumerate(sorted(idxs), 1):
1126
+ labels[idx] = f"{labels[idx]} ({rank})"
1127
+ return labels
1128
+ for idxs in collided:
1129
+ for idx in idxs:
1130
+ tail = segs[idx][max(0, len(segs[idx]) - 1 - depth):len(segs[idx]) - 1]
1131
+ # `"/"` mirrors the CLI's `os.path.basename(os.path.dirname(p))
1132
+ # or "/"` fallback for a path with no parent segment.
1133
+ qualifier = "/".join(tail) or "/"
1134
+ labels[idx] = f"{bases[idx]} ({qualifier})"
1135
+ depth += 1
1136
+
1137
+
1138
+ # --- Inventory ---
1139
+
1140
+ @dataclass(frozen=True)
1141
+ class SensitiveInventory:
1142
+ """What verification knows about one render's project provenance.
1143
+
1144
+ `project_labels` is what the raw snapshot carried at its typed project
1145
+ display sites, `prepared_labels` what preparation emitted there, and
1146
+ `allowed_labels` what preparation was permitted to emit. All three come
1147
+ from `_map_project_display`, the SINGLE enumeration of those sites.
1148
+
1149
+ `all_strings` is populated only by `_collect_sensitive_inventory`, which
1150
+ is a diagnostic and test helper — NOT part of the render path. It walks
1151
+ the whole dataclass/mapping/sequence graph generically, which is useful
1152
+ for asking "does this snapshot carry X anywhere", but `_verify_output`
1153
+ never reads it, so paying for that walk on every `render()` bought
1154
+ nothing.
1155
+ """
1156
+ project_labels: frozenset[str] = frozenset()
1157
+ prepared_labels: frozenset[str] = frozenset()
1158
+ allowed_labels: frozenset[str] = frozenset()
1159
+ all_strings: frozenset[str] = frozenset()
1160
+
1161
+
1162
+ EMPTY_INVENTORY = SensitiveInventory()
1163
+
1164
+
1165
+ def _walk_strings(value: object, out: set[str], seen: set[int]) -> None:
1166
+ if isinstance(value, str):
1167
+ if value:
1168
+ out.add(value)
1169
+ return
1170
+ if value is None or isinstance(value, (bool, int, float, bytes, datetime)):
1171
+ return
1172
+ marker = id(value)
1173
+ if marker in seen:
1174
+ return
1175
+ seen.add(marker)
1176
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
1177
+ for f in dataclasses.fields(value):
1178
+ _walk_strings(getattr(value, f.name, None), out, seen)
1179
+ return
1180
+ if isinstance(value, Mapping):
1181
+ for key, item in value.items():
1182
+ _walk_strings(key, out, seen)
1183
+ _walk_strings(item, out, seen)
1184
+ return
1185
+ if isinstance(value, (list, tuple, set, frozenset)):
1186
+ for item in value:
1187
+ _walk_strings(item, out, seen)
1188
+ return
1189
+
1190
+
1191
+ def _iter_chart_points(chart: "ChartSpec | None"):
1192
+ if chart is None:
1193
+ return
1194
+ yield from chart.points
1195
+ if isinstance(chart, LineChart) and chart.multi_series:
1196
+ for series in chart.multi_series.values():
1197
+ yield from series
1198
+ elif isinstance(chart, BarChart) and chart.stacks:
1199
+ for series in chart.stacks.values():
1200
+ yield from series
1201
+
1202
+
1203
+ def _collect_sensitive_inventory(snap: ShareSnapshot) -> SensitiveInventory:
1204
+ """Walk the whole snapshot graph and record every string it carries.
1205
+
1206
+ A DIAGNOSTIC and test helper, deliberately NOT on the render path. The
1207
+ generic walk answers "does this snapshot carry this token anywhere",
1208
+ which is what a test wants; `_verify_output` reads only the three
1209
+ project-provenance sets, so `render()` no longer pays for the walk.
1210
+ """
1211
+ strings: set[str] = set()
1212
+ _walk_strings(snap, strings, set())
1213
+ return SensitiveInventory(
1214
+ all_strings=frozenset(strings),
1215
+ project_labels=frozenset(_project_display_labels(snap)),
1216
+ )
1217
+
1218
+
1219
+ # --- Preparation ---
1220
+
1221
+ def _resolved_project_labels(
1222
+ snap: ShareSnapshot, *, reveal_projects: bool,
1223
+ ) -> dict[_ProjectAnonKey, str]:
1224
+ """Map each project identity to the label the document should show.
1225
+
1226
+ Alias keys are collected from the FULL labels first — before any basename
1227
+ reduction — so two projects sharing a basename stay two identities.
1228
+ """
1229
+ costs = _collect_project_identity_costs(snap)
1230
+ if not costs:
1231
+ return {}
1232
+ if not reveal_projects:
1233
+ return dict(_build_anon_mapping(costs))
1234
+ # Reveal: rank cost-descending (matching the alias ranking) so the
1235
+ # disambiguation order is stable, then reduce to displayed basenames.
1236
+ #
1237
+ # The basename comes from the DISPLAY LABEL, never from the key's second
1238
+ # element: for a legacy key those are the same string, but a qualified
1239
+ # key's second element is the opaque provider identity, and reducing that
1240
+ # would replace the user-facing label with an internal identifier.
1241
+ by_key = _project_label_by_key(snap)
1242
+ ordered = sorted(costs.items(), key=lambda kv: (-kv[1], kv[0]))
1243
+ keys = [key for key, _cost in ordered]
1244
+ display = disambiguate_basenames([by_key.get(key, key[1]) for key in keys])
1245
+ return {key: display[idx] for idx, key in enumerate(keys)}
1246
+
1247
+
1248
+ def _merged_project_costs(
1249
+ snaps: "Sequence[ShareSnapshot]",
1250
+ ) -> dict[_ProjectAnonKey, float]:
1251
+ """Accumulate project-identity costs across every section.
1252
+
1253
+ The `+=` matters: overwriting would let the last section's cost decide the
1254
+ global rank, so a project that appears twice would be ranked on half its
1255
+ spend.
1256
+ """
1257
+ costs: dict[_ProjectAnonKey, float] = {}
1258
+ for snap in snaps:
1259
+ for key, cost in _collect_project_identity_costs(snap).items():
1260
+ costs[key] = costs.get(key, 0.0) + cost
1261
+ return costs
1262
+
1263
+
1264
+ def _merged_project_mapping(
1265
+ snaps: "Sequence[ShareSnapshot]", *, reveal_projects: bool,
1266
+ ) -> dict[_ProjectAnonKey, str]:
1267
+ """One project display mapping shared by every section of a document.
1268
+
1269
+ Provider qualification is preserved deliberately: the same directory used
1270
+ under both Claude and Codex keeps two distinct aliases, which
1271
+ `test_equal_labels_with_distinct_qualified_identities_get_distinct_aliases`
1272
+ pins as a shipped invariant. Within one provider, one alias means one
1273
+ project.
1274
+
1275
+ The map is built BEFORE any basename reduction, so alias keys still derive
1276
+ from the full identities.
1277
+ """
1278
+ costs = _merged_project_costs(snaps)
1279
+ if not costs:
1280
+ return {}
1281
+ if not reveal_projects:
1282
+ return dict(_build_anon_mapping(costs))
1283
+ by_key: dict[_ProjectAnonKey, str] = {}
1284
+ for snap in snaps:
1285
+ for key, label in _project_label_by_key(snap).items():
1286
+ by_key.setdefault(key, label)
1287
+ ordered = sorted(costs.items(), key=lambda kv: (-kv[1], kv[0]))
1288
+ keys = [key for key, _cost in ordered]
1289
+ display = disambiguate_basenames([by_key.get(key, key[1]) for key in keys])
1290
+ return {key: display[idx] for idx, key in enumerate(keys)}
1291
+
1292
+
1293
+ def _merged_anon_mapping(
1294
+ sections: "Sequence[ComposedSection]",
1295
+ ) -> dict[_ProjectAnonKey, str]:
1296
+ """The anonymize-mode alias namespace for a composed document."""
1297
+ return _merged_project_mapping(
1298
+ [sec.snap for sec in sections], reveal_projects=False)
1299
+
1300
+
1301
+ # --- The single enumeration of project display sites -----------------------
1302
+ #
1303
+ # `_scrub` leaked because it hand-enumerated three field sites, and F1, F2
1304
+ # and the `x_label` fall-through were three instances of that one shape.
1305
+ # Replacing it with a second hand-enumeration would only move the shape:
1306
+ # preparation would rewrite a set of fields, provenance collection would read
1307
+ # a DIFFERENT set, and the two would drift the first time someone added a
1308
+ # project display field to only one of them.
1309
+ #
1310
+ # So there is exactly ONE enumeration, `_map_project_display`, and every
1311
+ # consumer derives from it:
1312
+ #
1313
+ # `_apply_project_mapping` rewrites the sites (preparation)
1314
+ # `_project_display_labels` reads them (provenance, both halves)
1315
+ # `_project_label_by_key` reads the KEYED ones (reveal-mode basenames)
1316
+ #
1317
+ # Adding a project display field therefore means editing `_map_project_display`
1318
+ # and nothing else; a field reachable by preparation but invisible to
1319
+ # provenance can no longer be constructed.
1320
+
1321
+
1322
+ @dataclass(frozen=True)
1323
+ class _ProjectDisplaySite:
1324
+ """One typed project display value, as seen by the single enumeration.
1325
+
1326
+ `kind` distinguishes the KEYED sites — whose value is a project label and
1327
+ therefore mints a `_ProjectAnonKey` — from the DERIVED `chart_x_label`
1328
+ site, whose value is composed from another site's resolution and must
1329
+ never be used as a key.
1330
+ """
1331
+ kind: str # "cell" | "column" | "chart_project_label"
1332
+ # | "chart_x_label"
1333
+ value: "str | None" # what is at the site right now
1334
+ identity: "str | None" = None # the project identity governing the site
1335
+ prefix: "str | None" = None # x_label_prefix (chart_x_label only)
1336
+ resolved: "str | None" = None # the label already resolved for this point
1337
+
1338
+ @property
1339
+ def keyed(self) -> bool:
1340
+ return self.kind != "chart_x_label"
1341
+
1342
+
1343
+ def _map_project_display(
1344
+ snap: ShareSnapshot, visit: "Callable[[_ProjectDisplaySite], str | None]",
1345
+ ) -> ShareSnapshot:
1346
+ """Walk every typed project display site, replacing each with `visit`.
1347
+
1348
+ THE enumeration. A read-only consumer passes a `visit` that records the
1349
+ site and returns its value unchanged; preparation passes one that resolves
1350
+ from the alias/basename mapping.
1351
+
1352
+ The sites, in full:
1353
+
1354
+ * `ProjectCell.label` for every `ProjectCell` in every row.
1355
+ * `ColumnSpec.label` for every column with `kind == "project"`.
1356
+ * `ChartPoint.project_label` for the chart's points, plus
1357
+ `LineChart.multi_series` and `BarChart.stacks`.
1358
+ * `ChartPoint.x_label` where the axis is project-keyed — derived from
1359
+ the point's resolved `project_label` rather than resolved on its own.
1360
+
1361
+ A `"plain"` axis is preserved untouched.
1362
+ """
961
1363
  new_rows: list[Row] = []
962
1364
  for row in snap.rows:
963
1365
  new_cells: dict[str, Cell] = {}
964
1366
  for key, cell in row.cells.items():
965
1367
  if isinstance(cell, ProjectCell):
966
- project_key = _project_anon_key(cell.label, cell.identity)
967
1368
  new_cells[key] = ProjectCell(
968
- tagged_mapping.get(project_key, "(unknown)"),
1369
+ visit(_ProjectDisplaySite(
1370
+ kind="cell", value=cell.label, identity=cell.identity)),
969
1371
  rank_cost=cell.rank_cost,
970
1372
  identity=cell.identity,
971
1373
  )
@@ -973,121 +1375,610 @@ def _apply_anon_mapping(
973
1375
  new_cells[key] = cell
974
1376
  new_rows.append(Row(cells=new_cells))
975
1377
 
976
- new_chart: ChartSpec | None = snap.chart
977
- if snap.chart is not None:
978
- def _rewrite_pt(p: ChartPoint) -> ChartPoint:
979
- if p.project_label:
980
- # Fail-safe: any label not in mapping (e.g., from drift between
981
- # _collect and _apply, or a future code path that adds chart
982
- # points after gather) is mapped to "(unknown)" rather than
983
- # passed through. Privacy invariant: never leak a non-anonymized
984
- # label, even if the gather pass missed it.
985
- project_key = _project_anon_key(p.project_label, p.project_identity)
986
- new_label = tagged_mapping.get(project_key, "(unknown)")
987
- else:
988
- new_label = None
989
- # x_label rewrite stays guarded — only anonymize if x_label is the
990
- # project axis AND the label is in mapping (preserves non-project
991
- # x_label values like time labels).
992
- if (p.project_label
993
- and p.x_label == p.project_label
994
- and project_key in tagged_mapping):
995
- new_x = tagged_mapping[project_key]
996
- else:
997
- new_x = p.x_label
998
- return ChartPoint(
999
- x_label=new_x,
1000
- x_value=p.x_value,
1001
- y_value=p.y_value,
1002
- project_label=new_label,
1003
- series_key=p.series_key,
1004
- project_identity=p.project_identity,
1005
- )
1378
+ def _rewrite_pt(p: ChartPoint) -> ChartPoint:
1379
+ new_label = (
1380
+ visit(_ProjectDisplaySite(
1381
+ kind="chart_project_label", value=p.project_label,
1382
+ identity=p.project_identity))
1383
+ if p.project_label else None
1384
+ )
1385
+ # Two independent triggers. `x_label_kind` is the authoritative one.
1386
+ # String equality is retained only because `_scrub` stays public and
1387
+ # a caller may hand it a hand-built point that predates the
1388
+ # discriminator; it is subsumed by the marker at every shipped
1389
+ # construction site, and it can never widen the leak surface — the
1390
+ # worst it can do is anonymize an axis that already displayed the
1391
+ # project name.
1392
+ is_project_axis = (
1393
+ p.x_label_kind == "project"
1394
+ or bool(p.project_label and p.x_label == p.project_label)
1395
+ )
1396
+ new_x = (
1397
+ visit(_ProjectDisplaySite(
1398
+ kind="chart_x_label", value=p.x_label,
1399
+ identity=p.project_identity, prefix=p.x_label_prefix,
1400
+ resolved=new_label))
1401
+ if is_project_axis else p.x_label
1402
+ )
1403
+ return ChartPoint(
1404
+ x_label=new_x,
1405
+ x_value=p.x_value,
1406
+ y_value=p.y_value,
1407
+ project_label=new_label,
1408
+ series_key=p.series_key,
1409
+ project_identity=p.project_identity,
1410
+ x_label_kind=p.x_label_kind,
1411
+ x_label_prefix=p.x_label_prefix,
1412
+ )
1006
1413
 
1007
- if isinstance(snap.chart, LineChart):
1008
- new_chart = LineChart(
1009
- points=tuple(_rewrite_pt(p) for p in snap.chart.points),
1010
- y_label=snap.chart.y_label,
1011
- reference_lines=snap.chart.reference_lines,
1012
- multi_series=(
1013
- {k: tuple(_rewrite_pt(p) for p in v)
1014
- for k, v in snap.chart.multi_series.items()}
1015
- if snap.chart.multi_series else None
1016
- ),
1017
- )
1018
- elif isinstance(snap.chart, BarChart):
1019
- new_chart = BarChart(
1020
- points=tuple(_rewrite_pt(p) for p in snap.chart.points),
1021
- y_label=snap.chart.y_label,
1022
- stacks=(
1023
- {k: tuple(_rewrite_pt(p) for p in v)
1024
- for k, v in snap.chart.stacks.items()}
1025
- if snap.chart.stacks else None
1026
- ),
1027
- )
1028
- elif isinstance(snap.chart, HorizontalBarChart):
1029
- new_chart = HorizontalBarChart(
1030
- points=tuple(_rewrite_pt(p) for p in snap.chart.points),
1031
- x_label=snap.chart.x_label,
1032
- cap=snap.chart.cap,
1033
- )
1414
+ new_chart: ChartSpec | None = snap.chart
1415
+ if isinstance(snap.chart, LineChart):
1416
+ new_chart = LineChart(
1417
+ points=tuple(_rewrite_pt(p) for p in snap.chart.points),
1418
+ y_label=snap.chart.y_label,
1419
+ reference_lines=snap.chart.reference_lines,
1420
+ multi_series=(
1421
+ {k: tuple(_rewrite_pt(p) for p in v)
1422
+ for k, v in snap.chart.multi_series.items()}
1423
+ if snap.chart.multi_series else None
1424
+ ),
1425
+ )
1426
+ elif isinstance(snap.chart, BarChart):
1427
+ new_chart = BarChart(
1428
+ points=tuple(_rewrite_pt(p) for p in snap.chart.points),
1429
+ y_label=snap.chart.y_label,
1430
+ stacks=(
1431
+ {k: tuple(_rewrite_pt(p) for p in v)
1432
+ for k, v in snap.chart.stacks.items()}
1433
+ if snap.chart.stacks else None
1434
+ ),
1435
+ )
1436
+ elif isinstance(snap.chart, HorizontalBarChart):
1437
+ new_chart = HorizontalBarChart(
1438
+ points=tuple(_rewrite_pt(p) for p in snap.chart.points),
1439
+ x_label=snap.chart.x_label,
1440
+ cap=snap.chart.cap,
1441
+ )
1034
1442
 
1035
- # Rewrite project-typed column headers (cross-tab Detail templates, issue
1036
- # #33). Fail-closed: any column.label not in `mapping` maps to "(unknown)",
1037
- # mirroring the ChartPoint arm above. Frozen-dataclass-compliant — we emit
1038
- # a new tuple of new ColumnSpec instances, never mutate snap.columns.
1039
1443
  new_columns: list[ColumnSpec] = []
1040
1444
  for col in snap.columns:
1041
1445
  if col.kind == "project":
1042
- new_label = tagged_mapping.get(
1043
- _project_anon_key(col.label, col.project_identity), "(unknown)",
1044
- )
1045
1446
  new_columns.append(ColumnSpec(
1046
- key=col.key, label=new_label,
1447
+ key=col.key,
1448
+ label=visit(_ProjectDisplaySite(
1449
+ kind="column", value=col.label,
1450
+ identity=col.project_identity)),
1047
1451
  align=col.align, emphasis=col.emphasis, kind=col.kind,
1048
1452
  project_identity=col.project_identity,
1049
1453
  ))
1050
1454
  else:
1051
1455
  new_columns.append(col)
1052
1456
 
1053
- # When ShareSnapshot grows a new field, add it to this constructor — the
1054
- # scrubber must thread every field through to preserve frozen semantics.
1055
- return ShareSnapshot(
1056
- cmd=snap.cmd,
1057
- title=snap.title,
1058
- subtitle=snap.subtitle,
1059
- period=snap.period,
1060
- columns=tuple(new_columns),
1061
- rows=tuple(new_rows),
1062
- chart=new_chart,
1063
- totals=snap.totals,
1064
- notes=snap.notes,
1065
- generated_at=snap.generated_at,
1066
- version=snap.version,
1067
- template_id=snap.template_id,
1068
- source=snap.source,
1069
- source_label=snap.source_label,
1070
- availability=snap.availability,
1071
- availability_reason=snap.availability_reason,
1457
+ return dataclasses.replace(
1458
+ snap, columns=tuple(new_columns), rows=tuple(new_rows), chart=new_chart,
1072
1459
  )
1073
1460
 
1074
1461
 
1075
- def _scrub(snap: ShareSnapshot, *, reveal_projects: bool) -> ShareSnapshot:
1076
- """Anonymize project labels unless reveal_projects is True.
1462
+ def _project_display_labels(snap: ShareSnapshot) -> set[str]:
1463
+ """The typed project display values present in one snapshot.
1077
1464
 
1078
- When reveal_projects is True, returns the SAME instance (identity preserved
1079
- so callers can rely on `out is snap`). When False, returns a NEW snapshot
1080
- with ProjectCell labels and ChartPoint project/x labels rewritten via
1081
- `_build_anon_mapping`. If no project labels are present in the snapshot,
1082
- also returns the original instance.
1465
+ Derived from `_map_project_display`, so it can never fall behind what
1466
+ preparation rewrites. The returned snapshot is discarded — the visitor
1467
+ returns each value unchanged, so this is a read.
1083
1468
  """
1084
- if reveal_projects:
1085
- return snap
1086
- project_costs = _collect_project_identity_costs(snap)
1087
- if not project_costs:
1088
- return snap
1089
- mapping = _build_anon_mapping(project_costs)
1090
- return _apply_anon_mapping(snap, mapping)
1469
+ labels: set[str] = set()
1470
+
1471
+ def _record(site: _ProjectDisplaySite) -> "str | None":
1472
+ if site.value:
1473
+ labels.add(site.value)
1474
+ return site.value
1475
+
1476
+ _map_project_display(snap, _record)
1477
+ return labels
1478
+
1479
+
1480
+ def _project_label_by_key(snap: ShareSnapshot) -> dict[_ProjectAnonKey, str]:
1481
+ """First-seen display label per project identity key.
1482
+
1483
+ Only the KEYED sites contribute: a project-keyed `x_label` is composed
1484
+ from another site's resolution, so keying on it would mint a bogus
1485
+ `("legacy", "1 · project-1")` entry.
1486
+ """
1487
+ out: dict[_ProjectAnonKey, str] = {}
1488
+
1489
+ def _record(site: _ProjectDisplaySite) -> "str | None":
1490
+ if site.keyed and site.value:
1491
+ out.setdefault(_project_anon_key(site.value, site.identity),
1492
+ site.value)
1493
+ return site.value
1494
+
1495
+ _map_project_display(snap, _record)
1496
+ return out
1497
+
1498
+
1499
+ def _apply_project_mapping(
1500
+ snap: ShareSnapshot, mapping: dict[_ProjectAnonKey, str],
1501
+ ) -> ShareSnapshot:
1502
+ """Rewrite every typed project display field from `mapping`.
1503
+
1504
+ Fail closed: a key absent from the mapping resolves to `(unknown)` rather
1505
+ than falling through to the original value. The `x_label` arm used to be
1506
+ the one exception — it fell OPEN on a mapping miss while its sibling arms
1507
+ fell closed — which is the asymmetry this replaces.
1508
+
1509
+ A project-keyed axis composes its `x_label` from the resolved label, with
1510
+ the `x_label_prefix` (the cost rank) ahead of it when present.
1511
+ """
1512
+ def _resolve(site: _ProjectDisplaySite) -> str:
1513
+ if site.kind == "chart_x_label":
1514
+ base = site.resolved if site.resolved is not None else ANON_UNKNOWN
1515
+ return f"{site.prefix} · {base}" if site.prefix else base
1516
+ if not site.value:
1517
+ return ANON_UNKNOWN
1518
+ return mapping.get(_project_anon_key(site.value, site.identity),
1519
+ ANON_UNKNOWN)
1520
+
1521
+ return _map_project_display(snap, _resolve)
1522
+
1523
+
1524
+ def _prepare(
1525
+ snap: ShareSnapshot, *, reveal_projects: bool,
1526
+ mapping: "dict[_ProjectAnonKey, str] | None" = None,
1527
+ ) -> ShareSnapshot:
1528
+ """Resolve every typed project display field and stamp provenance.
1529
+
1530
+ Always returns a NEW object and never marks its input, so the caller's
1531
+ snapshot stays renderable more than once. `mapping` lets `compose()`
1532
+ supply one merged alias namespace shared across all its sections; when
1533
+ omitted the mapping is derived from this snapshot alone.
1534
+ """
1535
+ if _is_prepared(snap):
1536
+ raise SharePreparationError(
1537
+ "snapshot is already prepared; entry points must receive raw "
1538
+ "snapshots (a second pass renumbers aliases on the legacy path "
1539
+ "and merges distinct projects in compose)"
1540
+ )
1541
+ originals = _project_display_labels(snap)
1542
+ resolved = (
1543
+ mapping if mapping is not None
1544
+ else _resolved_project_labels(snap, reveal_projects=reveal_projects)
1545
+ )
1546
+ out = _apply_project_mapping(snap, resolved)
1547
+ allowed = set(resolved.values())
1548
+ allowed.add(ANON_UNKNOWN)
1549
+ # A project-keyed axis emits `f"{prefix} · {resolved}"`, so the composed
1550
+ # forms belong on the allowlist too. They are derived from the RAW
1551
+ # snapshot's prefixes crossed with the mapping's values, never read back
1552
+ # off the prepared output, so the check stays a real comparison against
1553
+ # provenance rather than a tautology.
1554
+ prefixes = {
1555
+ point.x_label_prefix
1556
+ for point in _iter_chart_points(snap.chart)
1557
+ if point.x_label_kind == "project" and point.x_label_prefix
1558
+ }
1559
+ allowed |= {
1560
+ f"{prefix} · {value}" for prefix in prefixes for value in set(allowed)
1561
+ }
1562
+ object.__setattr__(out, _PREPARED_ATTR, _PreparedProvenance(
1563
+ reveal_projects=reveal_projects,
1564
+ originals=frozenset(originals),
1565
+ allowed=frozenset(allowed),
1566
+ ))
1567
+ return out
1568
+
1569
+
1570
+ def _inventory_for(
1571
+ raw: ShareSnapshot, prepared: ShareSnapshot,
1572
+ ) -> SensitiveInventory:
1573
+ """Build the verification inventory from one raw/prepared snapshot pair."""
1574
+ return _merge_inventories([(raw, prepared)])
1575
+
1576
+
1577
+ def has_project_identities(snap: ShareSnapshot) -> bool:
1578
+ """True when this snapshot carries a project identity the privacy toggle
1579
+ can act on (#503 S1 B1).
1580
+
1581
+ Public, because the dashboard's render handler surfaces it so the share
1582
+ modal's status line can tell the user what the export will actually
1583
+ contain. Some renders produce artifacts that are byte-identical in both
1584
+ privacy modes apart from the `anonymized:` frontmatter line. Telling that
1585
+ user "Export will show real project names" is a false statement there, and
1586
+ a warning learned to be false on Forecast is one a user may disregard on
1587
+ Projects.
1588
+
1589
+ WHICH renders those are is not a property of the code and is deliberately
1590
+ not enumerated anywhere. It is a property of the snapshot actually built:
1591
+ the same template carries project names over one store and none over
1592
+ another, and the split runs WITHIN a panel as well as between panels, so
1593
+ neither a panel list nor a template list can be right. Three independent
1594
+ counts taken during this session disagreed for exactly that reason — each
1595
+ was measured against a different dataset. Do not restore a number here.
1596
+
1597
+ Derived from `_project_display_labels`, hence from `_map_project_display`,
1598
+ the single enumeration of typed project display sites. A panel list would
1599
+ be a second source of truth and would be wrong at template granularity.
1600
+
1601
+ `(unknown)` does not count: it renders identically in both modes, so a
1602
+ snapshot carrying only it has nothing the toggle can change.
1603
+ """
1604
+ return bool(_project_display_labels(snap) - {ANON_UNKNOWN})
1605
+
1606
+
1607
+ def _merge_inventories(
1608
+ pairs: "Sequence[tuple[ShareSnapshot, ShareSnapshot]]",
1609
+ ) -> SensitiveInventory:
1610
+ """Fold the project provenance of every raw/prepared section pair.
1611
+
1612
+ Deliberately does NOT populate `all_strings`: `_verify_output` never
1613
+ reads it, so walking the whole snapshot graph here made every `render()`
1614
+ pay for a value nothing consumed. `_collect_sensitive_inventory` still
1615
+ offers that walk as a diagnostic.
1616
+ """
1617
+ originals: set[str] = set()
1618
+ prepared_labels: set[str] = set()
1619
+ allowed: set[str] = set()
1620
+ for raw, prepared in pairs:
1621
+ originals |= _project_display_labels(raw)
1622
+ prepared_labels |= _project_display_labels(prepared)
1623
+ prov = _provenance_of(prepared)
1624
+ if prov is not None:
1625
+ allowed |= set(prov.allowed)
1626
+ return SensitiveInventory(
1627
+ project_labels=frozenset(originals),
1628
+ prepared_labels=frozenset(prepared_labels),
1629
+ allowed_labels=frozenset(allowed),
1630
+ )
1631
+
1632
+
1633
+ # --- Verification: the forbidden-class detector (#503 S1) ---
1634
+ #
1635
+ # Stage 4 of the contract. DETECTION ONLY — a finding raises
1636
+ # `SharePrivacyViolation` and the render fails. It never redacts and
1637
+ # continues, because a redact-and-continue gate hides the builder defect that
1638
+ # put the identifier in the document, and the operator's decision is that a
1639
+ # share artifact which cannot be produced safely is not produced.
1640
+ #
1641
+ # Verification has TWO DISJOINT HALVES, and the split is load-bearing.
1642
+ #
1643
+ # Half one — provenance-checked fields. Preparation knows which fields it
1644
+ # rewrote and what it was allowed to write there, so verification compares the
1645
+ # emitted values against that allowlist. It never searches the document for
1646
+ # original project labels.
1647
+ #
1648
+ # Half two — unambiguous classes, scanned document-wide. Only identifier
1649
+ # classes that cannot plausibly occur as legitimate artifact content.
1650
+ #
1651
+ # Original project labels are deliberately NOT in half two. A project
1652
+ # legitimately named `cctally` collides with the static branding string this
1653
+ # module emits, so a whole-document rejected-token set would fail a correctly
1654
+ # anonymized artifact; `daily` and `svg` collide with ordinary chrome and
1655
+ # markup the same way. Under the fail-the-render decision that is an outage,
1656
+ # not a nuisance, so half one covers the real risk without the collision.
1657
+
1658
+
1659
+ class SharePrivacyViolation(Exception):
1660
+ """A forbidden identifier class was found in a rendered share artifact.
1661
+
1662
+ `classes` names the finding classes and NOTHING else — the message carries
1663
+ the matched value, this attribute deliberately does not. The two audiences
1664
+ differ: a user staring at a several-hundred-row artifact cannot act on
1665
+ "canonical UUID" alone, so the message names the value; a log line has no
1666
+ such need, and a dashboard log is a plausible thing to paste into a bug
1667
+ report (#503 S1 R10).
1668
+
1669
+ The default is the fail-closed sentinel rather than an empty tuple: a
1670
+ caller that redacts by reading `classes` treats a falsy value as "nothing
1671
+ to redact by" and falls back to the full `repr`, so a raise site that
1672
+ forgot the keyword would put the matched value into the log — the exact
1673
+ outcome this attribute exists to prevent, and silently. With the sentinel
1674
+ the redaction is uninformative instead of unsafe. The structural tripwire
1675
+ `test_every_privacy_raise_site_names_its_classes` keeps raise sites from
1676
+ relying on it.
1677
+ """
1678
+
1679
+ UNCLASSIFIED = "unclassified privacy violation"
1680
+
1681
+ def __init__(
1682
+ self, message: str, *, classes: "Sequence[str]" = (UNCLASSIFIED,),
1683
+ ) -> None:
1684
+ super().__init__(message)
1685
+ self.classes: tuple[str, ...] = tuple(classes) or (self.UNCLASSIFIED,)
1686
+
1687
+
1688
+ # Canonical UUID with exact 8-4-4-4-12 hex grouping. Claude session ids are
1689
+ # canonical UUIDs, which is what F1 disclosed through the sessions charts.
1690
+ _UUID_RE = re.compile(
1691
+ r"(?<![0-9A-Fa-f-])"
1692
+ r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"
1693
+ r"(?![0-9A-Fa-f-])"
1694
+ )
1695
+
1696
+ # A URI with a scheme and an authority. Stripped BEFORE the absolute-path scan
1697
+ # so its path component cannot be read as a filesystem path — the shipped
1698
+ # branded goldens carry `https://github.com/omrikais/cctally` and
1699
+ # `http://www.w3.org/2000/svg`, and a naive path predicate matches both.
1700
+ _URI_RE = re.compile(r"""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>)\]]*""")
1701
+
1702
+ # An absolute POSIX path of at least two segments. The lookbehind is the
1703
+ # exclusion rule: a `/` preceded by a word character, `:`, `/`, `<` or an
1704
+ # attribute quote is a URI or markup component, not a path start. That covers
1705
+ # the bare-host form `github.com/omrikais/cctally` (preceded by `m`), the
1706
+ # scheme-relative form (preceded by `/`), and closing / self-closing tags
1707
+ # (preceded by `<` or forming a single segment).
1708
+ _ABS_PATH_RE = re.compile(
1709
+ r"""(?<![A-Za-z0-9._\-:/<"'])(?:/[A-Za-z0-9._~+\-]+){2,}"""
1710
+ )
1711
+
1712
+ # `~/`, `~user/`, `$HOME/`, `${HOME}/` home expansions. The trailing separator
1713
+ # is required so the `~` prefix `blocks` uses for a heuristically-anchored row
1714
+ # is not a finding.
1715
+ _HOME_EXPANSION_RE = re.compile(
1716
+ r"""(?<![A-Za-z0-9._\-])(?:~[A-Za-z0-9._\-]*|\$\{?HOME\}?)/[A-Za-z0-9._~+\-]"""
1717
+ )
1718
+
1719
+ _EMAIL_RE = re.compile(
1720
+ r"(?<![A-Za-z0-9._%+\-])[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"
1721
+ r"(?![A-Za-z0-9.\-])"
1722
+ )
1723
+
1724
+ # `source_root_key` / `codex_file_key` are 32-character lowercase hex. The
1725
+ # lookarounds keep this from matching inside a 64-character sha256 digest.
1726
+ _SOURCE_ROOT_KEY_RE = re.compile(r"(?<![0-9A-Fa-f])[0-9a-f]{32}(?![0-9A-Fa-f])")
1727
+
1728
+ # The canonical logical-limit JSON object `_lib_jsonl._codex_logical_limit_key`
1729
+ # emits. Matched on the co-occurrence of its identity members rather than on
1730
+ # the whole literal, so a member added later still trips it.
1731
+ _LOGICAL_LIMIT_MEMBERS = ('"observedSlot"', '"windowMinutes"', '"sourceRootKey"')
1732
+
1733
+ _V1_IDENTITY_RE = re.compile(r"(?<![A-Za-z0-9._\-])v1\.[A-Za-z0-9_\-]{16,}")
1734
+ _V1_IDENTITY_MEMBERS = frozenset(
1735
+ {"nativeKey", "resourceKind", "source", "version"})
1736
+
1737
+ # High-precision credential shapes. These mirror
1738
+ # `_lib_conversation_anon.SECRET_PATTERNS`, deliberately COPIED rather than
1739
+ # imported: that module's guarantee explicitly excludes emails, session ids and
1740
+ # unknown identities, so reusing it here would import a weaker contract than
1741
+ # this gate promises. They are used as DETECTION, never as redaction.
1742
+ #
1743
+ # LEFT BOUNDARY, and why it is mandatory HERE specifically. The source
1744
+ # patterns carry no left anchor, so `sk-[A-Za-z0-9_\-]{20,}` matches inside
1745
+ # any word ending in `sk` — `flask-restful-api-server-example`,
1746
+ # `risk-analysis-toolkit-2026`, `desk-booking-service-frontend` — and
1747
+ # `sk-ant-[…]` matches inside `flask-ant-design-theme-kit`. In the source
1748
+ # module an unanchored match only OVER-REDACTS. Here detection FAILS the
1749
+ # render, so the same regex is a shipping outage: the user cannot rename
1750
+ # their repository in order to share a report. `_CRED_LEFT` requires the
1751
+ # match to start at a non-word position, which is where a real credential
1752
+ # always starts (after whitespace, a quote, `=`, `:` or the string start).
1753
+ #
1754
+ # WHAT THE ANCHOR GIVES UP. `_CRED_LEFT` excludes `-` and `_` from the
1755
+ # preceding position as well as alphanumerics, so a credential glued to a
1756
+ # word character on its left — `-sk-ant-api03-…` in a flag-like string, or
1757
+ # `KEY_sk-ant-…` — is no longer detected. That narrowing is deliberate and is
1758
+ # the price of the anchor: every realistic embedding of a real credential in a
1759
+ # rendered artifact (after a space, a quote, `=`, `:`, `/`, or at the start of
1760
+ # the string) still fires, while the ordinary-repository-name false positives
1761
+ # above, which under the fail-the-render decision leave the user no recourse,
1762
+ # do not.
1763
+ _CRED_LEFT = r"(?<![A-Za-z0-9_\-])"
1764
+ _CREDENTIAL_RES = (
1765
+ ("authorization-header", re.compile(r"\bAuthorization:[ \t]*\S", re.I)),
1766
+ ("bearer-token", re.compile(r"\bBearer[ \t]+[A-Za-z0-9._~+/=-]{16,}", re.I)),
1767
+ ("anthropic-key", re.compile(_CRED_LEFT + r"sk-ant-[A-Za-z0-9_\-]{8,}")),
1768
+ ("generic-sk-key", re.compile(_CRED_LEFT + r"sk-[A-Za-z0-9_\-]{20,}")),
1769
+ ("github-token", re.compile(
1770
+ _CRED_LEFT + r"(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{16,}")),
1771
+ ("aws-access-key", re.compile(_CRED_LEFT + r"AKIA[0-9A-Z]{16}")),
1772
+ ("slack-token", re.compile(_CRED_LEFT + r"xox[baprs]-[A-Za-z0-9\-]{10,}")),
1773
+ ("secret-assignment", re.compile(
1774
+ r"\b(?:api[_-]?key|secret|passwd|password)\b[ \t]*[=:][ \t]*"
1775
+ r"""(?:"[^"\r\n]{6,}"|'[^'\r\n]{6,}'|[^\s"']{6,})""", re.I)),
1776
+ )
1777
+
1778
+ _NUMERIC_ENTITY_RE = re.compile(r"&#(x[0-9A-Fa-f]+|[0-9]+);")
1779
+ _MD_BACKSLASH_RE = re.compile(r"\\([\\|*_`\[\]])")
1780
+
1781
+
1782
+ def _decode_entities(text: str) -> str:
1783
+ """Undo the XML/HTML escaping the renderers apply."""
1784
+ def _numeric(m: "re.Match[str]") -> str:
1785
+ token = m.group(1)
1786
+ try:
1787
+ code = int(token[1:], 16) if token[0] in "xX" else int(token)
1788
+ except ValueError:
1789
+ return m.group(0)
1790
+ return chr(code) if 0 < code < 0x110000 else m.group(0)
1791
+
1792
+ out = _NUMERIC_ENTITY_RE.sub(_numeric, text)
1793
+ for entity, char in (("&quot;", '"'), ("&#39;", "'"), ("&lt;", "<"),
1794
+ ("&gt;", ">"), ("&amp;", "&")):
1795
+ out = out.replace(entity, char)
1796
+ return out
1797
+
1798
+
1799
+ def _scan_variants(text: str) -> tuple[str, ...]:
1800
+ """The raw document plus its decoded forms.
1801
+
1802
+ Entity encoding and markdown backslash escaping both split a token across
1803
+ characters the scanners would otherwise not join up, so scanning the raw
1804
+ bytes alone would let an encoded identifier through.
1805
+ """
1806
+ decoded = _decode_entities(text)
1807
+ unescaped = _MD_BACKSLASH_RE.sub(r"\1", decoded)
1808
+ variants = [text]
1809
+ for candidate in (decoded, unescaped):
1810
+ if candidate not in variants:
1811
+ variants.append(candidate)
1812
+ return tuple(variants)
1813
+
1814
+
1815
+ def _looks_like_identity_key(token: str) -> bool:
1816
+ """True when a `v1.` token base64url-decodes to a canonical IdentityV1."""
1817
+ body = token[3:]
1818
+ padded = body + "=" * (-len(body) % 4)
1819
+ try:
1820
+ raw = base64.urlsafe_b64decode(padded.encode("ascii"))
1821
+ payload = json.loads(raw.decode("utf-8"))
1822
+ except Exception:
1823
+ return False
1824
+ return isinstance(payload, dict) and _V1_IDENTITY_MEMBERS <= set(payload)
1825
+
1826
+
1827
+ # How much of an offending value the refusal message quotes.
1828
+ #
1829
+ # The message reaches a terminal (`cctally: refused to write a share artifact
1830
+ # — …`), so an unbounded value would wrap the refusal off screen. Long enough
1831
+ # that a user recognizes which of their directories or identifiers tripped the
1832
+ # gate, which is the whole reason the value is named at all.
1833
+ _FINDING_SAMPLE_MAX = 48
1834
+
1835
+
1836
+ def _finding_sample(value: str) -> str:
1837
+ """A single-line, length-bounded form of a matched value."""
1838
+ collapsed = " ".join(value.split())
1839
+ if len(collapsed) <= _FINDING_SAMPLE_MAX:
1840
+ return collapsed
1841
+ return collapsed[:_FINDING_SAMPLE_MAX] + "…"
1842
+
1843
+
1844
+ # The run of characters a path-shaped token may continue with. Used ONLY to
1845
+ # widen a match for display: `_HOME_EXPANSION_RE` requires a single character
1846
+ # after the separator, which is the right anchor for detection but reports
1847
+ # `~/w` as the offending value where the user needs to see `~/work/app`.
1848
+ # Widening here rather than in the pattern keeps detection semantics fixed.
1849
+ _PATHY_TAIL_RE = re.compile(r"[A-Za-z0-9._~+\-/]*")
1850
+
1851
+
1852
+ def _matched_with_pathy_tail(text: str, match: "re.Match[str]") -> str:
1853
+ tail = _PATHY_TAIL_RE.match(text, match.end())
1854
+ return text[match.start():tail.end()]
1855
+
1856
+
1857
+ def _scan_forbidden_classes(text: str) -> "list[tuple[str, str]]":
1858
+ """Return `(class label, matched value)` per unambiguous class in `text`.
1859
+
1860
+ The matched value is carried out of the scan, not just the class name.
1861
+ Naming only the class leaves the user nothing to act on: the accepted
1862
+ limitation in `docs/share-gotchas.md` is that a project whose basename is
1863
+ itself a canonical UUID or a 32-character hex token cannot be rendered in
1864
+ reveal mode, and "canonical UUID" alone does not tell that user which of
1865
+ their directories to rename.
1866
+ """
1867
+ findings: list[tuple[str, str]] = []
1868
+ match = _UUID_RE.search(text)
1869
+ if match:
1870
+ findings.append(("canonical UUID", match.group(0)))
1871
+ for token in _V1_IDENTITY_RE.findall(text):
1872
+ if _looks_like_identity_key(token):
1873
+ findings.append(("v1. identity key", token))
1874
+ break
1875
+ without_uris = _URI_RE.sub(" ", text)
1876
+ match = _ABS_PATH_RE.search(without_uris)
1877
+ if match:
1878
+ findings.append(("absolute path", match.group(0)))
1879
+ match = _HOME_EXPANSION_RE.search(text)
1880
+ if match:
1881
+ findings.append(
1882
+ ("home-directory expansion", _matched_with_pathy_tail(text, match)))
1883
+ match = _EMAIL_RE.search(text)
1884
+ if match:
1885
+ findings.append(("email address", match.group(0)))
1886
+ match = _SOURCE_ROOT_KEY_RE.search(text)
1887
+ if match:
1888
+ findings.append(("source-root key", match.group(0)))
1889
+ if all(member in text for member in _LOGICAL_LIMIT_MEMBERS):
1890
+ # No single regex match to quote; the members are what identify it.
1891
+ first = min(_LOGICAL_LIMIT_MEMBERS, key=text.index)
1892
+ findings.append(("logical-limit identity", first))
1893
+ for name, pattern in _CREDENTIAL_RES:
1894
+ match = pattern.search(text)
1895
+ if match:
1896
+ findings.append((f"credential ({name})", match.group(0)))
1897
+ return findings
1898
+
1899
+
1900
+ def _describe_findings(findings: "Sequence[tuple[str, str]]") -> str:
1901
+ """Render `label (value)` per class, deduplicated, in a stable order."""
1902
+ seen: dict[str, str] = {}
1903
+ for label, value in findings:
1904
+ seen.setdefault(label, value)
1905
+ parts = [
1906
+ f"{label} ({_finding_sample(seen[label])})" for label in sorted(seen)
1907
+ ]
1908
+ if len(parts) > 5:
1909
+ return ", ".join(parts[:5]) + f", and {len(parts) - 5} more"
1910
+ return ", ".join(parts)
1911
+
1912
+
1913
+ def _verify_output(
1914
+ text: str, *, inventory: SensitiveInventory,
1915
+ ) -> None:
1916
+ """Raise `SharePrivacyViolation` when the rendered document is unsafe.
1917
+
1918
+ Detection only. Callers must let the exception propagate: the dashboard
1919
+ handler's exception converter turns it into the generic 500 envelope and
1920
+ the CLI converts it to a stderr refusal and exit 3.
1921
+
1922
+ Takes NO privacy mode. Both halves are mode-independent — half one
1923
+ compares against the allowlist preparation itself built under whichever
1924
+ mode was asked for, and half two's classes are forbidden in reveal mode
1925
+ too, because reveal discloses a project's basename and never its path.
1926
+ The parameter existed and was read by nothing.
1927
+ """
1928
+ # Half one — provenance. Every project display value the prepared snapshot
1929
+ # carries must be one preparation was allowed to write. This catches a
1930
+ # preparation miss without a text search, so a project whose name happens
1931
+ # to be a common word is checked correctly.
1932
+ #
1933
+ # A CONSTRUCTION INVARIANT, not a runtime check that can fire in the real
1934
+ # pipeline: `_apply_project_mapping` writes every site from
1935
+ # `mapping.get(key, ANON_UNKNOWN)` and composes the axis label from the
1936
+ # same values, and `_prepare` builds `allowed` from that mapping plus the
1937
+ # raw prefixes, so the difference is empty by construction for every
1938
+ # in-tree input. It fires for a snapshot prepared outside `_prepare`, and
1939
+ # it is deliberately NOT strengthened into a document-wide search for
1940
+ # original project labels: a project named `cctally` collides with this
1941
+ # module's own branding string, and under the fail-the-render decision
1942
+ # that collision is an outage.
1943
+ if inventory.allowed_labels or inventory.prepared_labels:
1944
+ escaped = inventory.prepared_labels - inventory.allowed_labels
1945
+ if escaped:
1946
+ raise SharePrivacyViolation(
1947
+ "project display fields escaped preparation: "
1948
+ + ", ".join(sorted(escaped)[:5]),
1949
+ # The escaped values ARE project labels, so they stay out of
1950
+ # `classes` for the same reason a matched value does.
1951
+ classes=("project display fields escaped preparation",),
1952
+ )
1953
+
1954
+ # Half two — unambiguous classes, document-wide, in both privacy modes.
1955
+ for variant in _scan_variants(text):
1956
+ findings = _scan_forbidden_classes(variant)
1957
+ if findings:
1958
+ raise SharePrivacyViolation(
1959
+ "share artifact would disclose: " + _describe_findings(findings),
1960
+ classes=sorted({label for label, _value in findings}),
1961
+ )
1962
+
1963
+
1964
+ def _encode_probe_identity_key() -> str:
1965
+ """A syntactically valid IdentityV1 key, for the detector's own tests.
1966
+
1967
+ Kept next to the decoder so the probe cannot drift away from the shape the
1968
+ detector recognizes.
1969
+ """
1970
+ payload = {
1971
+ "nativeKey": "probe",
1972
+ "parentKey": None,
1973
+ "resourceKind": "conversation",
1974
+ "source": "codex",
1975
+ "sourceRootKey": None,
1976
+ "version": 1,
1977
+ }
1978
+ canonical = json.dumps(
1979
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
1980
+ ).encode("utf-8")
1981
+ return "v1." + base64.urlsafe_b64encode(canonical).decode("ascii").rstrip("=")
1091
1982
 
1092
1983
 
1093
1984
  # --- Format renderers ---
@@ -1746,16 +2637,25 @@ def _build_md_frontmatter(snap: ShareSnapshot) -> str:
1746
2637
  `template_id` is present for dashboard share-v2 snapshots and omitted
1747
2638
  for legacy CLI snapshots that have no template recipe.
1748
2639
 
1749
- `anonymized` reflects whether `_scrub` has rewritten this snapshot --
1750
- detected via `_snapshot_is_anonymized` (label-prefix heuristic; see
1751
- that function for the contract).
2640
+ `anonymized` reports the MODE the document was rendered in, read off the
2641
+ provenance marker preparation stamped: `not reveal_projects`. It used to
2642
+ be INFERRED by regex-matching `project-\\d+` over the labels, which was
2643
+ wrong three ways — it never inspected the chart, so `sessions-visual`
2644
+ stamped `false` onto a demonstrably scrubbed snapshot; a real project
2645
+ named `project-1` reported as anonymized; and a silently failed scrub
2646
+ producing conforming labels was indistinguishable from a successful one.
2647
+
2648
+ An UNPREPARED snapshot never went through the privacy contract, so the
2649
+ document cannot claim anonymization and reports `false`. That reaches
2650
+ only the `_render_md` back-compat shim; `render()` always prepares.
1752
2651
  """
1753
2652
  period = snap.period
1754
2653
  period_iso = (
1755
2654
  f"{_format_generated_at_iso(period.start)}.."
1756
2655
  f"{_format_generated_at_iso(period.end)}"
1757
2656
  )
1758
- anonymized = "true" if _snapshot_is_anonymized(snap) else "false"
2657
+ prov = _provenance_of(snap)
2658
+ anonymized = "true" if (prov is not None and not prov.reveal_projects) else "false"
1759
2659
  lines = [
1760
2660
  "---",
1761
2661
  f"title: {_yaml_scalar(snap.title)}",
@@ -1790,41 +2690,6 @@ def _yaml_scalar(s: str) -> str:
1790
2690
  return s
1791
2691
 
1792
2692
 
1793
- def _snapshot_is_anonymized(snap: ShareSnapshot) -> bool:
1794
- """Return True if every project label (cell or column) is anon or sentinel.
1795
-
1796
- `_scrub` rewrites labels to `project-<N>` (1-indexed, cost-descending).
1797
- A snapshot with no `ProjectCell` rows AND no `kind='project'` columns
1798
- returns False (nothing was anonymized because there was nothing to
1799
- anonymize). `(unknown)` is the project-share sentinel for missing
1800
- project_path (see `cmd_project`'s `_proj_label_for`) — it is never a
1801
- revealed real name, so it is counted as also-anonymized. Mixed snapshots
1802
- (some scrubbed, some revealed) are reported False to keep the
1803
- frontmatter semantic ("are projects revealed in this MD?").
1804
-
1805
- Cross-tab Detail templates (issue #33) carry project labels in
1806
- `kind='project'` columns rather than `ProjectCell` rows; we walk both
1807
- surfaces so MD frontmatter `anonymized:` stays correct for those panels.
1808
- """
1809
- cells = [
1810
- cell
1811
- for row in snap.rows
1812
- for cell in row.cells.values()
1813
- if isinstance(cell, ProjectCell)
1814
- ]
1815
- project_cols = [col for col in snap.columns if col.kind == "project"]
1816
- if not cells and not project_cols:
1817
- return False
1818
-
1819
- def _is_anon(label: str) -> bool:
1820
- return bool(re.fullmatch(r"project-\d+", label)) or label == "(unknown)"
1821
-
1822
- return (
1823
- all(_is_anon(c.label) for c in cells)
1824
- and all(_is_anon(col.label) for col in project_cols)
1825
- )
1826
-
1827
-
1828
2693
  # --- Fragment + wrap ---
1829
2694
 
1830
2695
  def _render_fragment(snap: ShareSnapshot, *, format: str,
@@ -1906,21 +2771,41 @@ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> s
1906
2771
  wraps them all in composite chrome (one title, one footer, one outer
1907
2772
  wrapper) per format-specific stitching rules in spec §4.3.
1908
2773
 
1909
- `opts.reveal_projects` does NOT scrub here — scrubbing must have
1910
- happened upstream (in the API layer) before the snapshot reaches
1911
- this function. The kernel is anon-agnostic at compose time; the
1912
- composer endpoint is the chokepoint.
2774
+ The second complete-document boundary that owns the privacy contract
2775
+ (#503 S1). `sections` must carry RAW snapshots: `compose()` prepares them
2776
+ itself under `opts.reveal_projects`, stitches, and then verifies the whole
2777
+ composed document. Callers must not pre-scrub — a pre-scrubbed section
2778
+ reaching a second aliasing pass merges two distinct projects that each
2779
+ mapped locally to `project-1` into one alias.
1913
2780
  """
1914
2781
  if not sections:
1915
2782
  raise ValueError("compose requires at least one section")
1916
2783
  fmt = opts.format
2784
+ # ONE alias namespace for the whole document (#503 S1 F4). The merged
2785
+ # mapping is built here in the kernel rather than in the handler, because
2786
+ # a handler-only fix would miss the CLI `source=all` path.
2787
+ merged = _merged_project_mapping(
2788
+ [sec.snap for sec in sections], reveal_projects=opts.reveal_projects)
2789
+ prepared = tuple(
2790
+ ComposedSection(
2791
+ snap=_prepare(sec.snap, reveal_projects=opts.reveal_projects,
2792
+ mapping=merged),
2793
+ drift_detected=sec.drift_detected,
2794
+ )
2795
+ for sec in sections
2796
+ )
1917
2797
  if fmt == "html":
1918
- return _stitch_html(sections, opts=opts)
1919
- if fmt == "md":
1920
- return _stitch_md(sections, opts=opts)
1921
- if fmt == "svg":
1922
- return _stitch_svg(sections, opts=opts)
1923
- raise ValueError(f"unknown format: {fmt!r}")
2798
+ body = _stitch_html(prepared, opts=opts)
2799
+ elif fmt == "md":
2800
+ body = _stitch_md(prepared, opts=opts)
2801
+ elif fmt == "svg":
2802
+ body = _stitch_svg(prepared, opts=opts)
2803
+ else:
2804
+ raise ValueError(f"unknown format: {fmt!r}")
2805
+ _verify_output(body, inventory=_merge_inventories([
2806
+ (raw.snap, out.snap) for raw, out in zip(sections, prepared)
2807
+ ]))
2808
+ return body
1924
2809
 
1925
2810
 
1926
2811
  def _stitch_html(sections: tuple[ComposedSection, ...], *,
@@ -1975,11 +2860,11 @@ def _stitch_md(sections: tuple[ComposedSection, ...], *,
1975
2860
  # union collapses).
1976
2861
  earliest = min(sec.snap.period.start for sec in sections)
1977
2862
  latest = max(sec.snap.period.end for sec in sections)
1978
- anon_field = (
1979
- "true"
1980
- if all(_snapshot_is_anonymized(s.snap) for s in sections)
1981
- else "false"
1982
- )
2863
+ # #503 S1: the composite frontmatter reports the composite MODE.
2864
+ # `compose()` re-renders every section with `opts.reveal_projects`
2865
+ # and discards each section's add-time value, so the composite flag
2866
+ # is the whole truth about what the document contains.
2867
+ anon_field = "false" if opts.reveal_projects else "true"
1983
2868
  parts.append(
1984
2869
  "---\n"
1985
2870
  f"title: {_yaml_scalar(opts.title)}\n"
@@ -2043,16 +2928,39 @@ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
2043
2928
 
2044
2929
  # --- Public dispatch ---
2045
2930
 
2046
- def render(snap: ShareSnapshot, *, format: str, theme: str, branding: bool) -> str:
2931
+ def render(snap: ShareSnapshot, *, format: str, theme: str, branding: bool,
2932
+ reveal_projects: bool) -> str:
2047
2933
  """Render a snapshot to the requested format.
2048
2934
 
2049
2935
  Pure function: no I/O, no DB, no filesystem, no locks. Caller is
2050
2936
  responsible for emitting the result (stdout/file/clipboard/open).
2051
2937
 
2052
- Thin delegator over `_render_fragment` + `_wrap_document`: separates
2053
- body-only rendering from document chrome so compose can stitch multiple
2054
- sections under a single wrapper (M3.x).
2938
+ One of the two complete-document boundaries that own the privacy contract
2939
+ (#503 S1). It runs inventory -> prepare -> render -> verify. The gate goes
2940
+ here and in `compose()`, not in `_render_fragment` and not in
2941
+ `_wrap_document`, because composition bypasses the latter and a fragment
2942
+ is not a complete document.
2943
+
2944
+ `reveal_projects` is a REQUIRED keyword with no default. Three shipped
2945
+ sites had defaulted it open, and fixing three defaults leaves nothing
2946
+ stopping a fourth; a wrong default cannot exist where there is no
2947
+ default, so a caller that omits it raises `TypeError` at its own call
2948
+ site rather than silently revealing.
2949
+
2950
+ `snap` must be RAW. Passing an already-scrubbed or already-prepared
2951
+ snapshot renumbers aliases on the legacy path, so preparation refuses it.
2055
2952
  """
2953
+ inventory_source = snap
2954
+ prepared = _prepare(snap, reveal_projects=reveal_projects)
2955
+ out = _render_prepared(prepared, format=format, theme=theme,
2956
+ branding=branding)
2957
+ _verify_output(out, inventory=_inventory_for(inventory_source, prepared))
2958
+ return out
2959
+
2960
+
2961
+ def _render_prepared(snap: ShareSnapshot, *, format: str, theme: str,
2962
+ branding: bool) -> str:
2963
+ """Fragment + chrome for one already-prepared snapshot."""
2056
2964
  if format == "md":
2057
2965
  frag = _render_fragment(snap, format="md", palette=PALETTE_LIGHT, branding=branding)
2058
2966
  return _wrap_document(frag, format="md", palette=PALETTE_LIGHT, snap=snap,