cctally 1.93.0 → 1.94.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.
@@ -144,7 +144,179 @@ def _journal_heal_incident(kind: str, name: str, now_utc: dt.datetime) -> dict:
144
144
  break
145
145
  except ValueError:
146
146
  continue
147
- return {"kind": kind, "name": name, "age_s": age_s}
147
+ return {"kind": kind, "name": name, "age_s": age_s, "shape": None}
148
+
149
+
150
+ def _incident_shape_token(incident) -> "str | None":
151
+ """One incident's `damage.preserved.shapeToken` (#496 S6 §7.2 / §3.4).
152
+
153
+ Read here rather than in the kernel, which takes no filesystem. A manifest
154
+ that cannot be read contributes no shape, which is the safe direction: a
155
+ missing shape cannot manufacture a recurrence.
156
+ """
157
+ try:
158
+ manifest = json.loads(
159
+ (incident / "manifest.json").read_text(encoding="utf-8")
160
+ )
161
+ except (OSError, ValueError):
162
+ return None
163
+ if not isinstance(manifest, dict):
164
+ return None
165
+ damage = manifest.get("damage")
166
+ preserved = damage.get("preserved") if isinstance(damage, dict) else None
167
+ token = preserved.get("shapeToken") if isinstance(preserved, dict) else None
168
+ return token if isinstance(token, str) and token else None
169
+
170
+
171
+ def _gather_heal_detections(now_utc: dt.datetime) -> "list | None":
172
+ """The durable heal ring, as `{heal_id, age_s}` per entry (§7.2).
173
+
174
+ A DETECTION is a ring entry keyed by `healId`, which is a different thing
175
+ from an incident: a declined or coalesced detection produces a ring entry
176
+ and no quarantine directory at all, and only the ring can report the RATE.
177
+ """
178
+ try:
179
+ import _cctally_store
180
+
181
+ events = _cctally_store.read_stats_heal_events()
182
+ except Exception:
183
+ return None
184
+ found = []
185
+ for event in events:
186
+ if not isinstance(event, dict):
187
+ continue
188
+ age_s = None
189
+ stamp = event.get("detectedAtUtc")
190
+ if isinstance(stamp, str) and stamp:
191
+ try:
192
+ parsed = dt.datetime.fromisoformat(stamp.replace("Z", "+00:00"))
193
+ age_s = int((now_utc - parsed).total_seconds())
194
+ except ValueError:
195
+ age_s = None
196
+ found.append({
197
+ "heal_id": str(event.get("healId") or ""),
198
+ "age_s": age_s,
199
+ "outcome": event.get("outcome"),
200
+ })
201
+ return found
202
+
203
+
204
+ def _gather_retained_artifacts(
205
+ now_utc: dt.datetime, *, deep: bool = False,
206
+ ) -> "dict | None":
207
+ """The read-only retention scan and plan behind `db.retained_artifacts`.
208
+
209
+ Takes NO lock and writes nothing — it is the same walk and the same kernel
210
+ plan `cctally db prune`'s preview runs. A malformed policy is reported
211
+ rather than repaired, because §6.5 makes an unreadable policy a FAIL and
212
+ not a fallback to the defaults.
213
+
214
+ **The walk and the planner are `deep`-gated, like the `quick_check` legs.**
215
+ This gather is reached from the TUI and the dashboard snapshot precompute
216
+ on every rebuild, not only from `GET /api/doctor`, and the pair costs tens
217
+ of milliseconds at the maintainer's corpus size and grows with it. What
218
+ stays in the shallow path is what an operator must act on and what costs
219
+ one file read and one glob: the policy resolution and the stuck reclaim
220
+ records.
221
+
222
+ `now_utc` is the gather's clock, which honours `CCTALLY_AS_OF`. Letting the
223
+ planner read the wall clock instead would make the age bound — and every
224
+ golden that depends on it — drift with the calendar.
225
+
226
+ The failure path names the exception CLASS rather than returning a bare
227
+ None. Doctor must not crash, but a degrade that says only "unavailable" is
228
+ indistinguishable from a healthy install with nothing retained, and a
229
+ programming error here reached four golden fixtures and made all four of
230
+ them vacuous before anything reported it.
231
+ """
232
+ now_epoch = now_utc.timestamp()
233
+ try:
234
+ import _cctally_retention
235
+
236
+ resolution = _cctally_retention.read_retention_policy()
237
+ stuck = [
238
+ {
239
+ "planId": record["planId"],
240
+ "memberIds": sorted(record["entries"]),
241
+ "stuck": bool(record.get("stuck")),
242
+ "ageSeconds": record.get("ageSeconds"),
243
+ }
244
+ for record in _cctally_retention.list_stuck_reclaim_records(
245
+ now_epoch=now_epoch,
246
+ )
247
+ ]
248
+ if resolution.status == "malformed":
249
+ # No byte figures are reported, rather than zeros: the scan never
250
+ # ran, and a zero here is a false measurement a consumer would
251
+ # render as "nothing retained".
252
+ return {
253
+ "policy_status": "malformed",
254
+ "policy_reason": resolution.reason,
255
+ "stuck_records": stuck,
256
+ }
257
+ bounds = {
258
+ "max_age_seconds": resolution.policy.max_age_seconds,
259
+ "max_count_per_family": resolution.policy.max_count_per_family,
260
+ "max_total_bytes": resolution.policy.max_total_bytes,
261
+ "min_free_bytes": resolution.policy.min_free_bytes,
262
+ }
263
+ if not deep:
264
+ return {
265
+ "policy_status": "not-scanned",
266
+ "policy_reason": None,
267
+ "stuck_records": stuck,
268
+ **bounds,
269
+ }
270
+ scan, plan, graph = _cctally_retention.plan_retention(
271
+ policy=resolution.policy, now_epoch=now_epoch, with_graph=True,
272
+ )
273
+ return {
274
+ "policy_status": resolution.status,
275
+ "policy_reason": None,
276
+ "retained_bytes": plan.before_bytes,
277
+ "reclaimable_bytes": plan.reclaimable_bytes,
278
+ "protected_bytes": _retention_protected_bytes(scan, plan, graph),
279
+ "protected_roots": len(plan.protected_ids),
280
+ "roots": len(plan.delete_ids) + len(plan.keep_ids)
281
+ + len(plan.protected_ids),
282
+ "free_disk_bytes": scan.free_disk_bytes,
283
+ "partial_scan": bool(scan.partial),
284
+ "unsatisfied_rules": list(plan.unsatisfied_rules),
285
+ # Which bounds actually SELECTED something. The WARN summary used
286
+ # to name the byte budget whatever drove the reclamation, the same
287
+ # false sentence the FAIL summary printed.
288
+ "driving_rules": [
289
+ rule for rule in _cctally_retention._kernel.BOUND_ORDER
290
+ if rule in set(plan.reasons.values())
291
+ ],
292
+ "floor_retained_roots": len(plan.floor_retained_ids),
293
+ "floor_retained_bytes": plan.floor_retained_bytes,
294
+ "stuck_records": stuck,
295
+ **bounds,
296
+ }
297
+ except Exception as exc: # noqa: BLE001 — doctor never crashes
298
+ # Read-only diagnostics never fail the gather; the leg degrades. The
299
+ # class is carried so the degrade is STATED rather than silent.
300
+ return {"policy_status": "unavailable", "scan_error": type(exc).__name__}
301
+
302
+
303
+ def _retention_protected_bytes(scan, plan, graph=None) -> int:
304
+ """Bytes held by protected roots, counted once across shared members.
305
+
306
+ The graph is handed in. Letting `summarize_prune` build its own made the
307
+ doctor leg construct the reference graph TWICE per gather, once inside
308
+ `plan_retention` and once here, for a structure neither call mutates.
309
+ """
310
+ try:
311
+ import _cctally_retention
312
+
313
+ return int(
314
+ _cctally_retention.summarize_prune(
315
+ scan, plan, graph=graph,
316
+ )["protectedBytes"]
317
+ )
318
+ except Exception:
319
+ return 0
148
320
 
149
321
 
150
322
  def _read_guard_log_tail(path) -> list[str]:
@@ -1770,8 +1942,13 @@ def _doctor_gather_state_impl(
1770
1942
  _incident_read_ok = True
1771
1943
  for entry in qroot.iterdir():
1772
1944
  if entry.is_dir():
1773
- _incidents.append(
1774
- _journal_heal_incident("quarantine", entry.name, now_utc))
1945
+ record = _journal_heal_incident(
1946
+ "quarantine", entry.name, now_utc)
1947
+ # §7.2 escalates on a REPEATED damage shape, so the shape
1948
+ # has to travel with the incident it belongs to — counted
1949
+ # once per incident, never once per manifest read.
1950
+ record["shape"] = _incident_shape_token(entry)
1951
+ _incidents.append(record)
1775
1952
  except OSError:
1776
1953
  pass
1777
1954
  try:
@@ -1791,6 +1968,12 @@ def _doctor_gather_state_impl(
1791
1968
  d["age_s"] if d["age_s"] is not None else 0))
1792
1969
  journal_heal_incidents = _incidents
1793
1970
 
1971
+ # #496 S6 §7.2 / §7.3. Both are read-only and take no lock; both degrade to
1972
+ # None rather than failing the gather, because `doctor` is reached from the
1973
+ # TUI and the dashboard snapshot precompute as well as from the CLI.
1974
+ journal_heal_detections = _gather_heal_detections(now_utc)
1975
+ retained_artifacts = _gather_retained_artifacts(now_utc, deep=deep)
1976
+
1794
1977
  # #386/#389 stats sole-writer guard log (spec §6.4). Read-only, fail-soft: an
1795
1978
  # absent log is the NORMAL state and must read as INFO, never as a gather
1796
1979
  # failure. Read only the bounded tail; rotation and cross-process throttling
@@ -1910,6 +2093,8 @@ def _doctor_gather_state_impl(
1910
2093
  journal_hw_segment=journal_hw_segment,
1911
2094
  journal_cursor_segment=journal_cursor_segment,
1912
2095
  journal_heal_incidents=journal_heal_incidents,
2096
+ journal_heal_detections=journal_heal_detections,
2097
+ retained_artifacts=retained_artifacts,
1913
2098
  journal_writer_guard=journal_writer_guard,
1914
2099
  # Multi-account attribution legs (#341).
1915
2100
  accounts_state=accounts_state,
@@ -995,6 +995,27 @@ def _load_breakdown(
995
995
  return [dict(r) for r in rows]
996
996
 
997
997
 
998
+ def _blocks_period_instant(raw: object) -> "dt.datetime":
999
+ """A block boundary as a real INSTANT for the artifact's period.
1000
+
1001
+ `block_start_at` and `five_hour_resets_at` are timestamps, not
1002
+ calendar labels, so they convert into the display zone (#503 S2 D7).
1003
+ Keeping only the UTC date part — which is what this site used to do —
1004
+ named the wrong civil day west of UTC and disagreed with the block's
1005
+ own row cell, which `format_display_dt` renders in the display zone.
1006
+
1007
+ It takes no zone argument. The conversion into the display zone
1008
+ happens in `period_civil_dates`, from the zone the `PeriodSpec` is
1009
+ labelled with, so a zone passed here would be a second authority that
1010
+ could disagree with the first (#503 S2 second review N8).
1011
+ """
1012
+ _c = _cctally()
1013
+ try:
1014
+ return parse_iso_datetime(str(raw), "five_hour_blocks.block_start_at")
1015
+ except (TypeError, ValueError):
1016
+ return _c._share_now_utc()
1017
+
1018
+
998
1019
  def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
999
1020
  """List API-anchored 5h blocks with rollup totals + 7d-drift columns."""
1000
1021
  _c = _cctally()
@@ -1172,11 +1193,13 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
1172
1193
  since_iso, args._resolved_tz,
1173
1194
  )
1174
1195
  elif block_dicts:
1175
- tail = block_dicts[-1].get("block_start_at")
1176
- period_start = _c._share_parse_date_to_dt(
1177
- (tail or "").split("T")[0] or None,
1178
- args._resolved_tz,
1179
- )
1196
+ # A block start is a real INSTANT, so it converts into the
1197
+ # display zone rather than being grounded (#503 S2 D7).
1198
+ # This used to keep only the UTC date part, which named
1199
+ # the wrong civil day west of UTC and disagreed with the
1200
+ # block's own row cell, rendered in the display zone.
1201
+ period_start = _blocks_period_instant(
1202
+ block_dicts[-1].get("block_start_at"))
1180
1203
  else:
1181
1204
  period_start = _c._share_now_utc()
1182
1205
  if until_iso:
@@ -1184,11 +1207,17 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
1184
1207
  until_iso, args._resolved_tz,
1185
1208
  )
1186
1209
  elif block_dicts:
1187
- head = block_dicts[0].get("block_start_at")
1188
- period_end = _c._share_parse_date_to_dt(
1189
- (head or "").split("T")[0] or None,
1190
- args._resolved_tz,
1191
- )
1210
+ # The newest block's END, not its start. A block runs for
1211
+ # five hours and the artifact's rows describe all of it,
1212
+ # so ending the stated period at 13:00 for a block that
1213
+ # runs to 18:00 understated what the artifact covers —
1214
+ # invisible while the period was rendered as a date and
1215
+ # visible the moment the frontmatter carried the full
1216
+ # timestamp (#503 S2 second review N5).
1217
+ newest = block_dicts[0]
1218
+ period_end = _blocks_period_instant(
1219
+ newest.get("five_hour_resets_at")
1220
+ or newest.get("block_start_at"))
1192
1221
  else:
1193
1222
  period_end = _c._share_now_utc()
1194
1223
  # Build a BlocksView from the API-anchored table rows
@@ -1208,8 +1237,6 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
1208
1237
  period_end=period_end,
1209
1238
  display_tz=display_tz_str,
1210
1239
  version=_c._share_resolve_version(),
1211
- theme=args.theme,
1212
- reveal_projects=args.reveal_projects,
1213
1240
  tz=args._resolved_tz,
1214
1241
  )
1215
1242
  _c._share_render_and_emit(snap, args)
@@ -1039,22 +1039,21 @@ def cmd_report(args: argparse.Namespace) -> int:
1039
1039
  # fixture goldens don't drift when the harness host's
1040
1040
  # wall-clock day rolls past CCTALLY_AS_OF.
1041
1041
  now_local = _command_as_of().astimezone(tz)
1042
- local_tz = now_local.tzinfo
1043
1042
  ws_d, we_d = compute_week_bounds(now_local, week_start_name)
1044
- ws_dt = dt.datetime.combine(
1045
- ws_d, dt.time.min, tzinfo=local_tz
1046
- )
1047
- we_dt = dt.datetime.combine(
1048
- we_d + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
1049
- )
1043
+ # Grounded in the zone the period is LABELLED with, not in
1044
+ # `now_local.tzinfo` (#503 S2 D7). That attribute is the
1045
+ # fixed offset in force right now, so a week that began
1046
+ # under a different DST offset was anchored an hour away
1047
+ # from its own midnight and could state the previous day.
1048
+ ws_dt = c._share_ground_civil_date(ws_d, tz)
1049
+ we_dt = c._share_ground_civil_date(
1050
+ we_d + dt.timedelta(days=1), tz)
1050
1051
  snap = c._build_report_snapshot(
1051
1052
  c.TrendView(),
1052
1053
  period_start=ws_dt,
1053
1054
  period_end=we_dt,
1054
1055
  display_tz=display_tz_str,
1055
1056
  version=c._share_resolve_version(),
1056
- theme=args.theme,
1057
- reveal_projects=args.reveal_projects,
1058
1057
  )
1059
1058
  c._share_render_and_emit(snap, args)
1060
1059
  return 0
@@ -1253,8 +1252,6 @@ def cmd_report(args: argparse.Namespace) -> int:
1253
1252
  period_end=period_end,
1254
1253
  display_tz=display_tz_str,
1255
1254
  version=c._share_resolve_version(),
1256
- theme=args.theme,
1257
- reveal_projects=args.reveal_projects,
1258
1255
  )
1259
1256
  c._share_render_and_emit(snap, args)
1260
1257
  return 0
@@ -1442,14 +1439,13 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1442
1439
  config, getattr(args, "week_start_name", None)
1443
1440
  )
1444
1441
  ws_date, we_date = compute_week_bounds(now_utc, week_start_name)
1445
- # internal fallback: host-local intentional
1446
- local_tz = dt.datetime.now().astimezone().tzinfo
1447
- week_start_dt = dt.datetime.combine(
1448
- ws_date, dt.time.min, tzinfo=local_tz
1449
- )
1450
- week_end_dt = dt.datetime.combine(
1451
- we_date + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
1452
- )
1442
+ # Grounded in the zone `display_tz_str` names (#503 S2 D7). It
1443
+ # used to be host-local, which disagrees with the label
1444
+ # whenever `display.tz` names another zone.
1445
+ _res_tz = getattr(args, "_resolved_tz", None)
1446
+ week_start_dt = c._share_ground_civil_date(ws_date, _res_tz)
1447
+ week_end_dt = c._share_ground_civil_date(
1448
+ we_date + dt.timedelta(days=1), _res_tz)
1453
1449
  # Pass `low_conf=False` + explicit notes: the issue is "no data
1454
1450
  # recorded yet," not "thin data." LOW CONF would mislead the
1455
1451
  # reader into thinking a projection ran with sparse samples.
@@ -1458,8 +1454,6 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1458
1454
  week_end=week_end_dt,
1459
1455
  display_tz=display_tz_str,
1460
1456
  version=c._share_resolve_version(),
1461
- theme=args.theme,
1462
- reveal_projects=args.reveal_projects,
1463
1457
  actual_series=[],
1464
1458
  projected_series=[],
1465
1459
  current_pct=0.0,
@@ -1564,8 +1558,6 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1564
1558
  week_end=i.week_end_at,
1565
1559
  display_tz=display_tz_str,
1566
1560
  version=c._share_resolve_version(),
1567
- theme=args.theme,
1568
- reveal_projects=args.reveal_projects,
1569
1561
  actual_series=actual_series,
1570
1562
  projected_series=projected_series,
1571
1563
  current_pct=float(i.p_now),
@@ -3057,7 +3049,7 @@ def _build_budget_snapshot(
3057
3049
  period_label = c._share_period_label(
3058
3050
  inputs.week_start_at, inputs.week_end_at, tz_label
3059
3051
  )
3060
- title = f"Budget — week of {inputs.week_start_at.strftime('%b %d')}"
3052
+ title = f"Budget — week of {inputs.week_start_at.date().isoformat()}"
3061
3053
  period_spec = _lib_share.PeriodSpec(
3062
3054
  start=inputs.week_start_at, end=inputs.week_end_at,
3063
3055
  display_tz=tz_label, label=period_label,
@@ -3082,15 +3074,12 @@ def _build_budget_snapshot(
3082
3074
  "value": _lib_share.MoneyCell(status.projected_eow_high_usd)}),
3083
3075
  )
3084
3076
  notes = ("LOW CONF — early in week",) if status.low_confidence else ()
3085
- subtitle = " · ".join([
3086
- period_label,
3087
- args.theme,
3088
- "real projects" if args.reveal_projects else "projects anonymized",
3089
- ])
3090
3077
  return _lib_share.ShareSnapshot(
3091
3078
  cmd="budget",
3092
3079
  title=title,
3093
- subtitle=subtitle,
3080
+ # #503 S2 D5 — the facts strip states the period and the
3081
+ # privacy mode; the theme is dropped deliberately.
3082
+ subtitle=None,
3094
3083
  period=period_spec,
3095
3084
  columns=columns,
3096
3085
  rows=rows,
@@ -3118,22 +3107,17 @@ def _build_budget_no_data_snapshot(args, budget_cfg, now_utc):
3118
3107
  config, getattr(args, "week_start_name", None)
3119
3108
  )
3120
3109
  ws_date, we_date = compute_week_bounds(now_utc, week_start_name)
3121
- # internal fallback: host-local intentional
3122
- local_tz = dt.datetime.now().astimezone().tzinfo
3123
- week_start_dt = dt.datetime.combine(ws_date, dt.time.min, tzinfo=local_tz)
3124
- week_end_dt = dt.datetime.combine(
3125
- we_date + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
3126
- )
3110
+ # Grounded in the zone `tz_label` names (#503 S2 D7), not host-local.
3111
+ week_start_dt = c._share_ground_civil_date(ws_date, tz)
3112
+ week_end_dt = c._share_ground_civil_date(we_date + dt.timedelta(days=1), tz)
3127
3113
  period_label = c._share_period_label(week_start_dt, week_end_dt, tz_label)
3128
3114
  target = budget_cfg["weekly_usd"]
3129
- subtitle = " · ".join([
3130
- period_label, args.theme,
3131
- "real projects" if args.reveal_projects else "projects anonymized",
3132
- ])
3133
3115
  return _lib_share.ShareSnapshot(
3134
3116
  cmd="budget",
3135
- title=f"Budget — week of {week_start_dt.strftime('%b %d')}",
3136
- subtitle=subtitle,
3117
+ title=f"Budget — week of {week_start_dt.date().isoformat()}",
3118
+ # #503 S2 D5 — the facts strip states the period and the
3119
+ # privacy mode; the theme is dropped deliberately.
3120
+ subtitle=None,
3137
3121
  period=_lib_share.PeriodSpec(
3138
3122
  start=week_start_dt, end=week_end_dt,
3139
3123
  display_tz=tz_label, label=period_label,
@@ -3172,14 +3156,12 @@ def _build_budget_no_budget_snapshot(args):
3172
3156
  tz = c.resolve_display_tz(args, config)
3173
3157
  tz_label = c._share_display_tz_label(tz)
3174
3158
  period_label = c._share_period_label(now_utc, now_utc, tz_label)
3175
- subtitle = " · ".join([
3176
- period_label, args.theme,
3177
- "real projects" if args.reveal_projects else "projects anonymized",
3178
- ])
3179
3159
  return _lib_share.ShareSnapshot(
3180
3160
  cmd="budget",
3181
3161
  title="Budget — no budget set",
3182
- subtitle=subtitle,
3162
+ # #503 S2 D5 — the facts strip states the period and the
3163
+ # privacy mode; the theme is dropped deliberately.
3164
+ subtitle=None,
3183
3165
  period=_lib_share.PeriodSpec(
3184
3166
  start=now_utc, end=now_utc, display_tz=tz_label, label=period_label,
3185
3167
  ),
@@ -5668,6 +5668,27 @@ def _run_stats_ingest_once(
5668
5668
  if own_conn and conn is not None:
5669
5669
  conn.close()
5670
5670
  finally:
5671
+ # §9.2 (#496 S6 F23): the routine stats write. The `-wal`/`-shm`
5672
+ # sidecars are materialized by the FIRST write, not by the connect, so
5673
+ # hardening at open time alone would leave a 0644 WAL behind every
5674
+ # ingest cycle — the exact shape of the cache.db defect #150 fixed.
5675
+ # This runs while the ingest lock is still held, so the sidecars it
5676
+ # inspects are the ones this cycle produced.
5677
+ #
5678
+ # Guarded because it is the FIRST statement of this block and the two
5679
+ # lock releases are the last two: anything raised here — including the
5680
+ # import — skips both, and the flocks are fd-scoped, so a long-lived
5681
+ # dashboard process would hold them until it exited. Hardening is
5682
+ # best-effort; releasing the locks is not.
5683
+ try:
5684
+ import _cctally_store
5685
+ _cctally_store._harden_stats_family(_cctally_core.DB_PATH)
5686
+ except Exception as exc: # noqa: BLE001 — never above a lock release
5687
+ print(
5688
+ f"[ingest] could not harden the stats family ({exc}); "
5689
+ "continuing",
5690
+ file=sys.stderr,
5691
+ )
5671
5692
  if lock_fd is not None:
5672
5693
  _release_ingest_lock(lock_fd)
5673
5694
  if maintenance_fd is not None:
@@ -7055,14 +7076,14 @@ def _record_post_checkpoint_damage(
7055
7076
 
7056
7077
 
7057
7078
  def _binary_version() -> "str | None":
7058
- """The running binary's released version, or None when it cannot be read."""
7059
- try:
7060
- import _lib_changelog
7079
+ """The running binary's released version, or None when it cannot be read.
7061
7080
 
7062
- value = _lib_changelog._read_latest_changelog_version()
7063
- except Exception: # pragma: no cover — a missing CHANGELOG is not fatal
7064
- return None
7065
- return value[0] if value else None
7081
+ One implementation, in `_cctally_db`, shared with the incident manifests
7082
+ the quarantine path writes (#496 S6 §4.2).
7083
+ """
7084
+ import _cctally_db
7085
+
7086
+ return _cctally_db._binary_version()
7066
7087
 
7067
7088
 
7068
7089
  def _preserve_stats_family_for_cutover(
@@ -7494,6 +7515,7 @@ def _publish_stats_index_in_place(
7494
7515
  no incident directory), or `_FALL_BACK` when physical replacement is the
7495
7516
  sanctioned response.
7496
7517
  """
7518
+ import _cctally_store
7497
7519
  import _lib_stats_publish as sp
7498
7520
 
7499
7521
  try:
@@ -7607,6 +7629,11 @@ def _publish_stats_index_in_place(
7607
7629
  conn.close()
7608
7630
  except Exception:
7609
7631
  pass
7632
+ # §9.2 (#496 S6 F23): the in-place publisher never touches the destination
7633
+ # file's mode — it mutates objects inside it — so a family that was 0644
7634
+ # before the publication is still 0644 after it. The checkpoint above is
7635
+ # also the last thing that can re-materialize a sidecar.
7636
+ _cctally_store._harden_stats_family(destination)
7610
7637
  _stats_rebuild_test_pause("rebuild_after_publication_replace")
7611
7638
 
7612
7639
  # Phase 2: validate the bytes that are now live, on a connection that never
@@ -7780,6 +7807,12 @@ def _publish_rebuilt_stats_index(
7780
7807
  )
7781
7808
 
7782
7809
  _stats_rebuild_test_pause("rebuild_before_cutover")
7810
+ # §9.2 (#496 S6 F23): the scratch is closed and about to BECOME the
7811
+ # destination, so hardening it here is what makes the replacement private
7812
+ # from the instant it is visible under the live name. `os.replace` carries
7813
+ # the source inode's mode across; a chmod after the rename would leave a
7814
+ # window in which the live index was world-readable.
7815
+ _cctally_store._harden_stats_family(scratch)
7783
7816
  os.replace(str(scratch), str(destination))
7784
7817
  _fsync_dir(destination.parent)
7785
7818
  _stats_rebuild_test_pause("rebuild_after_publication_replace")
@@ -7787,6 +7820,10 @@ def _publish_rebuilt_stats_index(
7787
7820
  # Phase 2: validate the bytes that are now live, on a connection that never
7788
7821
  # saw them being written.
7789
7822
  post_error = validate_published_stats_index(destination, high_water)
7823
+ # The validation opened the family read-only, which materializes sidecars
7824
+ # the removal below deletes; harden the destination itself while they are
7825
+ # still present so neither the main nor a surviving sidecar stays 0644.
7826
+ _cctally_store._harden_stats_family(destination)
7790
7827
  # The read-only open above creates a zero-byte WAL and a 32 KiB SHM.
7791
7828
  # Remove them so the documented no-post-publication-stale-sidecar end state
7792
7829
  # still holds; an empty WAL is consistent with the freshly published main,
@@ -8949,6 +8986,41 @@ def rebuild_stats_index(
8949
8986
  high_water: "tuple[str, int] | None" = None,
8950
8987
  update_quota_cache: bool = True,
8951
8988
  before_swap=None,
8989
+ ) -> RebuildResult:
8990
+ """Rebuild the stats index under a SHARED `artifact-retention.lock` hold.
8991
+
8992
+ #496 S6 §5.3. A rebuild publishes three artifacts that reclamation must not
8993
+ mark while they are being written: the preserved-family incident manifest,
8994
+ the second manifest write `_record_post_checkpoint_damage` performs after
8995
+ the explicit checkpoint, and the rebuild record that names both. The hold
8996
+ spans all three, so no observer ever sees the incident half-described.
8997
+
8998
+ The hold is taken here rather than only at the producer call sites because
8999
+ `db rebuild` and the auto-heal worker are not the only callers — the epoch
9000
+ rebuild and the deferred rebuild reach the same cutover. It is refcounted,
9001
+ so a caller that already holds it pays nothing.
9002
+
9003
+ See `_rebuild_stats_index_locked` for the rebuild itself.
9004
+ """
9005
+ import _cctally_retention
9006
+
9007
+ with _cctally_retention.retention_shared(label="stats rebuild"):
9008
+ return _rebuild_stats_index_locked(
9009
+ context=context,
9010
+ target_path=target_path,
9011
+ high_water=high_water,
9012
+ update_quota_cache=update_quota_cache,
9013
+ before_swap=before_swap,
9014
+ )
9015
+
9016
+
9017
+ def _rebuild_stats_index_locked(
9018
+ *,
9019
+ context: RebuildContext,
9020
+ target_path=None,
9021
+ high_water: "tuple[str, int] | None" = None,
9022
+ update_quota_cache: bool = True,
9023
+ before_swap=None,
8952
9024
  ) -> RebuildResult:
8953
9025
  """Build a FRESH stats index from the journal alone (spec §5.4).
8954
9026