cctally 1.93.1 → 1.94.1

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.94.1] - 2026-08-08
9
+
10
+ ### Fixed
11
+ - Release validation now runs reliably across Linux filesystems: corruption-recovery fixtures use the existing guarded test-copy path when reflinks are unavailable, identity-swap fixtures force a genuinely different inode even when the filesystem immediately reuses one, and doctor goldens normalize filesystem-specific allocated blocks without hiding a missing measurement.
12
+
13
+ ## [1.94.0] - 2026-08-08
14
+
15
+ ### Added
16
+ - Every share artifact now states, directly under its title, the period it covers, the timezone those dates are in, and whether its project names are anonymized — in Markdown, HTML and SVG alike, from the command line and from the dashboard. Most artifacts said none of this: twenty-seven of the forty-three places a snapshot is built passed no subtitle, and the renderer gated both the subtitle and the generation timestamp on that one field, so those exports could not be dated or bounded by the person reading them. A report whose data contains no project names states the period and the timezone and stops, rather than claiming a privacy mode it has no basis for, and the claim is read from what the render pipeline actually did rather than guessed from the labels — so a project genuinely named `project-1` is reported as a real name (#503).
17
+ - `cctally budget --format html` and `--format svg` now show the `ok`/`warn`/`over` verdict and the budget target. Both live in the artifact's totals, which HTML and SVG rendered nowhere, so those two formats silently omitted the two figures the report exists to deliver. Totals now appear once in each of the three formats (#503).
18
+ - Renaming a saved share preset now keeps the preset. It was carried out as a create-then-delete pair that rebuilt the record from four fields, so a renamed Codex preset came back labelled Claude, its "Saved at" time was reset even though the recipe had not changed, and renaming onto a name another preset already held destroyed that preset without asking. Renaming is now one operation on the server, which moves the stored record whole, and both renaming and saving onto an existing name ask you to confirm the replacement first (#503).
19
+ - A multi-panel composed export now appears in Recent shares. The composer's five export buttons recorded nothing at all, so a composed report left no trace in the history the single-panel share modal writes to. A composed export is shown in every panel's list, labelled with how many sections it contained, and is a record rather than a shortcut — clicking it does nothing, because restoring a basket from history does not exist (#503).
20
+ - `cctally doctor` gained a `db.retained_artifacts` check that reports how much disk the retained corruption evidence occupies, how much of it is reclaimable, and how much is protected. It warns when a reclamation is due and would bring you back inside your policy, and fails when evidence cctally cannot classify is what holds you over a bound, or when the retention policy is malformed. Both messages name the rule that is actually unsatisfied — the age bound, the per-family count, the size budget or the free-disk floor — and all of them when more than one applies. The last surviving example of each distinct kind of damage is reported as information rather than as a problem, because keeping it is what your policy asks for. It also warns, once, if a reclamation left an entry nothing can finish — a file it cannot decide about, or one it is not permitted to delete — naming the plan and the file to inspect, because nothing else would ever mention it. If the scan itself cannot run, the check warns and names the reason rather than reading as a healthy install with nothing retained. The scan itself is measured only when you run `cctally doctor`; the dashboard and the terminal dashboard report "not scanned" instead, so their refresh does not pay for a walk of your data directory (#496).
21
+ - A new `cctally db prune` reclaims the corruption evidence cctally has retained — quarantine incidents, forensics bundles, rebuild records and the backups `db repair` writes for itself — down to what your `storage.artifact_retention` policy keeps. It previews by default and prints what it would delete, what it would keep, and why anything is protected, in whichever unit carries a real figure rather than rounding a small corpus to `0.00 GiB`; `--yes` applies it, `--include-backups` also reaches families you made with `cctally db backup`, and `--json` emits the whole plan. Four things are never deleted: evidence cctally could not determine the cause of, evidence something is still using, the last surviving example of each distinct kind of damage — one example per kind, for as many kinds as `max_shape_examples` allows — and any `.bak-*` file whose name cctally does not recognize. Deletion is crash-safe — every file is renamed to a tombstone and recorded before anything is unlinked, so an interrupted run is finished by the next one. If the policy block is present but malformed, the command deletes nothing and exits 2 in both modes (#496).
22
+ - Ordinary successful commands now schedule at most one background reclamation a day, so the retained evidence stays inside your policy without you running anything. It never runs from `cctally statusline`, `cctally doctor`, a report, a failed command, `cctally db prune` itself, or the preview half of a command that previews before it applies — `cctally record-credit`, `cctally db rederive` and `cctally db journal-repair` schedule nothing until you pass `--yes`. An interrupted reclamation is finished immediately rather than waiting for the next daily window. Set `CCTALLY_DISABLE_RETENTION_SWEEP=1` to switch the automatic pass off and reclaim by hand (#496).
23
+ - A new `storage.artifact_retention` setting bounds the on-disk evidence corruption recovery keeps: `cctally config set storage.artifact_retention '{"max_age_days": 14}'`. The block takes `max_age_days`, `max_count_per_family`, `max_total_mib` and `min_free_mib` — any of which may be `null` to switch that rule off, as long as one of the first three stays on — plus `max_shape_examples`, which is never nullable because the last surviving example of a distinct kind of damage is always kept. Fields you omit keep their default, so you can set one without restating the rest. The defaults are 30 days, 20 incidents per database family, 4096 MiB in total and a 10240 MiB free-disk floor, keeping 8 damage-shape examples. If the setting is present but malformed, cctally does not fall back to the defaults: it switches automatic reclamation off, reports the problem in `cctally doctor`, and refuses to prune until you fix or remove the block (#496).
24
+
25
+ ### Changed
26
+ - Share artifacts now print full dates. A report covering January 2020 to May 2026 read `Jan 01 → May 09` and named neither year; period labels, report titles and week, day and session cells are all full ISO now (#503).
27
+ - `--no-branding` now strips the advertisement and keeps the provenance. In Markdown it removed the entire frontmatter — the export's title, period, panel and privacy mode went with the version stamp — while HTML and SVG lost only their footer and kept their timestamps. It now removes the `Generated by cctally` footer and the `cctally_version` key, and nothing else, in all three formats (#503).
28
+ - The timezone a share artifact names is now a concrete zone. Command-line artifacts labelled every period `(local)`, which names no zone at all, so a reader could not tell which day boundary the dates used. The configuration tokens `local` and `utc` are resolved before the artifact is built, and the period is computed in the zone it is labelled with — a five-hour block or a session that began at 03:30 UTC is now dated as the previous day in an American zone, as it should be, while a daily or monthly bucket keeps the calendar day it is named after (#503).
29
+ - A composed multi-panel document now names cctally and its version in its footer, as a single-panel one does. It read only `cctally · composed`, so stitching two panels together dropped the provenance either of them carried on its own (#503).
30
+ - A composed multi-panel document now has one top-level heading and one heading per section. Markdown printed each section's title twice, at two different ranks, and HTML wrapped a composite `<h1>` around one `<h1>` per section. When a document combines Claude and Codex sections of the same report, each heading now names its provider, because both sections otherwise carry the same title (#503).
31
+ - Clearing the report basket, deleting a preset, and replacing one by saving or renaming over it now ask before they act. All four committed on the first click, and `Clear all` wrote straight through to the browser's storage with nothing to undo it. Each now shows a confirmation naming exactly what it is about to destroy — how many sections, or which preset — and Escape or Cancel leaves everything as it was (#503).
32
+ - The composer's per-section menu no longer offers "Preview only this". It was wired to nothing: clicking it did nothing and said nothing. The documentation that described it has been corrected along with several other claims about affordances the share surface does not have (#503).
33
+ - Everywhere cctally describes what `stats.db` is worth, it now tells you which of two situations you are in instead of assuming the worse one. When cctally has retained journal data for your installation — which it does on any install that has been through the journal cutover — `stats.db` is a disposable index derived from that journal: cctally rebuilds or heals it automatically, and deleting it loses nothing. Only on a pre-cutover installation with no retained journal data may `stats.db` be the only copy of your recorded history, and there cctally preserves it, refuses to replace it with an empty rebuild, and points you at `cctally db repair --db stats --yes` instead. Thirteen places said the second thing unconditionally — the file map, the `doctor` integrity check and its remediation, the `db repair` and `db recover` help and refusals, and the message printed when an open finds the index damaged — which told most users their disposable index was irreplaceable and left them running a guarded repair where a rebuild was the answer. `docs/runtime-data.md` also documents, for the first time, every artifact corruption recovery leaves behind: the quarantined incidents, the forensics bundles and their write-ahead-log evidence, the rebuild records, the heal-event ring and the classification files — each with what bounds it and the standing rule that evidence cctally could not determine the cause of is never deleted automatically (#496).
34
+ - `cctally doctor`'s auto-heal check now tells you how often corruption is happening, not just that it happened once. It states how many incidents there are and how long ago the most recent one was — in hours when it was today, rather than reporting everything under a day as "0d ago" — and it escalates to a failure when three heals occur within a week, or when the same kind of damage appears in two separate incidents within a week. A forensics bundle is no longer counted as an incident of its own, so a quarantine directory and the bundle written moments before it read as one incident rather than two (#496).
35
+
36
+ ### Fixed
37
+ - Public snapshot releases now preserve GitHub contributor credit without merging an unadopted implementation or adding a second public commit. The private release record queues each contributor's Git-linked name and email plus its issue/PR scope, and each release binds the next identity as its single snapshot commit's primary author — the metadata GitHub's repository contributor endpoint actually counts — while its tree, message, tag annotation, CHANGELOG body, privacy boundary, and resume guarantees stay unchanged. The first fix-forward record credits @darlingm for public issue #6 and PR #7, whose safer in-house implementation shipped in v1.92.0/v1.92.1. A same-release retry also now refuses an existing conflicting tag before moving public `main`, closing the tree-neutral attribution hazard that could append a duplicate snapshot and only then fail (#506).
38
+ - The public-mirror dependency guard now rejects public tests that hide private fixture dependencies behind dynamic path segments or irrelevant gate-call metadata, preventing missing-fixture failures that appear only in the public clone (#523).
39
+ - A dark-theme share artifact now prints legibly. The print stylesheet set black text on the page body and stopped there, but every element in the document carries its own colour, so nothing inherited the correction and the pale grey text reached paper at a contrast ratio of 1.24 to 1 — effectively blank. The embedded chart was worse: it paints a near-black background rectangle that no rule about text colour could reach. Text, table backgrounds, the chart canvas and the chart's axis lines are all corrected now, and so are the chart's data colours: a printed dark forecast used to show its 90% ceiling line at a contrast of 1.67 to 1 — essentially invisible — while the light version of the same report printed clearly. Each reference line and its label now print the same colour — in the light theme as well as the dark one, where the label was going black above a line that stayed amber — so the colour that marks a threshold still means something on paper. Printing also fits the page again: the chart and the table each scroll inside their own box on screen, and on paper a scroll box is a crop, so the chart lost its right edge — where the bar values and the last axis label sit — and a long table printed its first page and dropped the rest. Both boxes are released when printing, and the chart is scaled to the sheet (#503).
40
+ - A composed document exported to a dark theme now has a visible title. The composite heading was the only text in the whole document with no colour of its own, so it fell back to the browser's default black on a near-black background. A composed SVG also gains the title, footer and background it never had: its section gaps showed through as transparent, and `--no-branding` had no effect on it at all, because there was no footer to remove (#503).
41
+ - Chart labels no longer run off the edge of the canvas, print on top of each other, or push the page sideways. The horizontal bar chart reserved a fixed gutter on the left and ten pixels on the right, so a revealed project name was clipped at the left edge and every value label — even `$0.01` — was clipped at the right; the line chart's y-axis label reserved nothing at all and started six pixels outside the canvas in every forecast export. Both edges are measured now and the canvas widens to fit, so bars are not shrunk and labels are not truncated. An axis whose labels cannot all fit — a 5-hour block chart, whose ticks are full timestamps — now drops the ones that do not fit rather than printing them over one another as a single unreadable string. The first label is always kept, and the last one is kept whenever it fits beside it (#503).
42
+ - An HTML export no longer scrolls sideways, and its chart stays readable on a narrow screen. The chart and the data table each scroll inside their own box now. Previously the chart was scaled down to fit the page instead — on a 420px screen its axis labels came out below six pixels tall — and the table, which is what actually pushed the page sideways, had no box at all, so its rightmost columns were reachable only by scrolling the whole document (#503).
43
+ - Chart text is measured by the width each letter actually takes, in every script. Every character was estimated at the same width, which under-reserved space for capitals by nearly forty per cent and over-reserved it for narrow letters by more than double — so a wide label was cut off while a narrow one wasted a third of the canvas, and the same estimate decides where an SVG table truncates a cell. Chinese, Japanese and Korean names and emoji were under-reserved by thirty to fifty per cent even after the first correction, so a project named in Chinese had its cost printed on top of its own name (#503).
44
+ - A share artifact now states a period that describes what that artifact actually shows, and the answer depends on the format. Markdown draws no chart, so a weekly Markdown recap states the week it is about while the HTML and SVG exports of the same report state the eight weeks their chart draws; previously all three claimed the eight weeks, so one week's spend sat under an eight-week heading. In the other direction, several `--source`-aware reports stated a period NARROWER than their own table — one of them a single instant above a row dated three days earlier — and now cover every row they print. `cctally five-hour-blocks` also ended its stated period at the newest block's start rather than its end. Each report's title still names the week or block it is about, and no report's period is ever narrowed: one that covered nine days and found rows on five still says nine (#503).
45
+ - The `period:` field in an exported Markdown file's frontmatter now ends after the last thing the export shows. Where the stated window had to be widened to cover a row the report displays, the machine-readable end was set to midnight of that day — so a row printing a full timestamp such as `2026-05-08T10:00:00+00:00` fell outside the range the file declared about itself, and a month bucket ended the range on the first of the month instead of the last. The dates a reader sees are unchanged (#503).
46
+ - A share artifact is now typographically the same file as an SVG and as an HTML page. Only the SVG table cells named a font, so everything else — the title, the period line, the timestamp, the footer and every chart label — fell back to whatever the viewer chose, which is a serif face in Chrome (#503).
47
+ - A chart-only Markdown export no longer carries a blank gap where its table would have been, and the Projects visual no longer emits an empty table header in any format — its description said it had no table while it declared one. No artifact draws a table header with no rows under it any more: an empty-result report used to print a four-column frame around nothing under a title that already read "no data", and one Codex report printed "Codex quota state is unavailable." and then drew the frame anyway (#503).
48
+ - The project column in a `--source`-aware report is labelled `Project` again. Its header was being anonymized along with the data, so the column was titled `project-1` — and in a two-provider report the header and the rows disagreed about which alias they meant, leaving the column effectively unlabelled (#503).
49
+ - A shared Codex quota block now states a full date. It carried the dashboard's compact `13:00 May 07` chip text, which names no year, while every other block export states the full timestamp (#503).
50
+ - `cctally <command> --source all --format …` now exits 3 when it refuses to write an artifact on privacy grounds, matching every single-source command. It exited 1 with a traceback instead, so the exit code the documentation promises was true of one path and not the other (#503).
51
+ - A basket section no longer goes "Outdated" just because a moment passed. The check that compares a section against current data was reading the clock at microsecond resolution: two exports of the same unchanged report a millisecond apart disagreed, so the badge was guaranteed for any section old enough to notice, and "Refresh from current data" could not clear it. It now compares what the report is made of — its title, period, rows, chart, totals and notes — so it moves when the report's own contents move and stays put otherwise. One consequence follows from that rule rather than from the clock: a current-week recap or detail prints how many days remain in the week to one decimal place, so such a section does still turn Outdated when that printed figure changes, which happens about every two and a half hours. Hiding a section's chart or table is no longer treated as a data change either, and two different five-hour windows on the same day are no longer treated as the same one (#503).
52
+ - An export that never happened is no longer reported as a success. Opening a report in a new tab did nothing at all when the browser blocked the pop-up, and printing reported success when there was no print dialog to open — and both wrote a Recent-shares entry for the export you did not get. Both now tell you what went wrong and record nothing, and both show a confirmation when they do succeed (#503).
53
+ - The share modal now warns you that the preview failed before you export, rather than after. A failed preview was visible only in the preview pane, so the export buttons beside it said nothing about it; the privacy line beside them also kept making a definite claim about project names it no longer had any basis for. The buttons stay available, because an export re-fetches and may well succeed (#503).
54
+ - `stats.db`, its write-ahead log files and the `logs/` directory are now created readable and writable only by you, matching `cache.db`, `conversations.db` and the data directory itself. **Nothing was exposed by this and no action is required:** cctally's data directory is already private to your account, so no other user on the machine could reach these files whatever their own permissions said. It is corrected because a copy of a file carries its own permissions with it, a data directory you create yourself need not be private, and a layer that disagrees with the ones around it becomes a real hole the moment one of them changes. cctally now checks the permissions each time it opens or writes the database and corrects them only when they are wrong, so an ordinary command does no extra work and a database whose permissions are changed underneath it is repaired on the next command rather than staying wrong until it is recreated (#496).
55
+
8
56
  ## [1.93.1] - 2026-08-07
9
57
 
10
58
  ### Fixed
@@ -8075,7 +8075,15 @@ def _cache_open_guarded() -> sqlite3.Connection:
8075
8075
  str(pid) for pid in sorted(open_pids)
8076
8076
  )
8077
8077
  )
8078
- _cctally_db_sib.quarantine_db_family(path, strict=True)
8078
+ # #496 S6 §5.3: a resume publishes the incident's final
8079
+ # manifest, so it is a producer too.
8080
+ import _cctally_retention as _retention_sib
8081
+ with _retention_sib.retention_shared(
8082
+ label="cache quarantine resume"
8083
+ ):
8084
+ _cctally_db_sib.quarantine_db_family(
8085
+ path, strict=True,
8086
+ )
8079
8087
  except OSError as exc:
8080
8088
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
8081
8089
  raise sqlite3.DatabaseError(
@@ -8330,6 +8338,12 @@ def _recover_corrupt_cache(
8330
8338
  _close_cache_trigger_connection_best_effort(active_conn)
8331
8339
  _cctally_db_sib._release_repair_marker(path, claim)
8332
8340
  raise
8341
+ # #496 S6 §5.3: this producer holds `artifact-retention.lock` SHARED from
8342
+ # before its forensics bundle through the final manifest, so reclamation
8343
+ # cannot mark evidence that is still being published. Released FIRST in the
8344
+ # finally, below the maintenance flock it sits under in the lock order.
8345
+ import _cctally_retention as _retention_sib
8346
+ retention = contextlib.ExitStack()
8333
8347
  try:
8334
8348
  fcntl.flock(lock_fh, fcntl.LOCK_EX)
8335
8349
  if active_conn is not None:
@@ -8353,6 +8367,9 @@ def _recover_corrupt_cache(
8353
8367
  # this snapshot earlier and restoring it after the probe would overwrite
8354
8368
  # that writer's newer WAL index.
8355
8369
  shm_snapshot = _capture_cache_shm_snapshot(path)
8370
+ retention.enter_context(
8371
+ _retention_sib.retention_shared(label="cache recovery")
8372
+ )
8356
8373
  try:
8357
8374
  forensics = _cctally_db_sib.write_corruption_forensics(
8358
8375
  path,
@@ -8397,7 +8414,17 @@ def _recover_corrupt_cache(
8397
8414
  )
8398
8415
  return False
8399
8416
  try:
8400
- incident = _cctally_db_sib.quarantine_db_family(path, strict=True)
8417
+ incident = _cctally_db_sib.quarantine_db_family(
8418
+ path,
8419
+ strict=True,
8420
+ # #496 S6 §4.2: this is the process that observed the
8421
+ # corruption, so it is the only one that can describe it.
8422
+ context=_cctally_db_sib.quarantine_context(
8423
+ trigger=origin,
8424
+ trigger_error=exc,
8425
+ forensics_path=forensics.path,
8426
+ ),
8427
+ )
8401
8428
  except OSError as quarantine_exc:
8402
8429
  raise sqlite3.DatabaseError(
8403
8430
  "cache.db recovery could not complete whole-family quarantine: "
@@ -8410,6 +8437,7 @@ def _recover_corrupt_cache(
8410
8437
  )
8411
8438
  return True
8412
8439
  finally:
8440
+ retention.close()
8413
8441
  if active_conn is not None:
8414
8442
  _close_cache_trigger_connection_best_effort(active_conn)
8415
8443
  try:
@@ -8791,9 +8819,15 @@ def _conversations_open_guarded(
8791
8819
  str(pid) for pid in sorted(open_pids)
8792
8820
  )
8793
8821
  )
8794
- _cctally_db_sib.quarantine_db_family(
8795
- path, strict=True,
8796
- )
8822
+ # #496 S6 §5.3: a resume publishes the incident's final
8823
+ # manifest, so it is a producer too.
8824
+ import _cctally_retention as _retention_sib
8825
+ with _retention_sib.retention_shared(
8826
+ label="conversations quarantine resume"
8827
+ ):
8828
+ _cctally_db_sib.quarantine_db_family(
8829
+ path, strict=True,
8830
+ )
8797
8831
  removed, reclaim_reason = (
8798
8832
  _cctally_db_sib._remove_stale_repair_marker(path)
8799
8833
  )
@@ -9291,6 +9325,9 @@ def _recover_corrupt_conversations(
9291
9325
 
9292
9326
  lock_fh = None
9293
9327
  provider_locks: list[Any] | None = None
9328
+ # #496 S6 §5.3, same span as the cache producer above.
9329
+ import _cctally_retention as _retention_sib
9330
+ retention = contextlib.ExitStack()
9294
9331
  try:
9295
9332
  lock_path = pathlib.Path(
9296
9333
  _cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
@@ -9323,6 +9360,9 @@ def _recover_corrupt_conversations(
9323
9360
  + "; leaving the live family untouched"
9324
9361
  ) from exc
9325
9362
 
9363
+ retention.enter_context(
9364
+ _retention_sib.retention_shared(label="conversations recovery")
9365
+ )
9326
9366
  try:
9327
9367
  with _conversation_probe_snapshot(path) as snapshot:
9328
9368
  forensics = _cctally_db_sib.write_corruption_forensics(
@@ -9370,7 +9410,15 @@ def _recover_corrupt_conversations(
9370
9410
  _conversation_recovery_test_pause("confirmed")
9371
9411
  try:
9372
9412
  incident = _cctally_db_sib.quarantine_db_family(
9373
- path, strict=True,
9413
+ path,
9414
+ strict=True,
9415
+ # #496 S6 §4.2: this is the process that observed the
9416
+ # corruption, so it is the only one that can describe it.
9417
+ context=_cctally_db_sib.quarantine_context(
9418
+ trigger=origin,
9419
+ trigger_error=exc,
9420
+ forensics_path=forensics.path,
9421
+ ),
9374
9422
  )
9375
9423
  except OSError as quarantine_exc:
9376
9424
  raise sqlite3.DatabaseError(
@@ -9391,6 +9439,7 @@ def _recover_corrupt_conversations(
9391
9439
  )
9392
9440
  return True
9393
9441
  finally:
9442
+ retention.close()
9394
9443
  if provider_locks is not None:
9395
9444
  _release_conversation_provider_locks(provider_locks)
9396
9445
  if lock_fh is not None:
@@ -40,6 +40,7 @@ from dataclasses import dataclass, field
40
40
  from datetime import timezone
41
41
 
42
42
  from _cctally_core import WEEKDAY_MAP, _command_as_of, eprint, get_week_start_name
43
+ from _lib_display_tz import _resolve_tz, resolve_display_tz_name
43
44
 
44
45
 
45
46
  UTC = timezone.utc
@@ -288,10 +289,39 @@ def _build_codex_share_snapshot(command: str, view, rows):
288
289
  c = _cctally()
289
290
  lib = c._share_load_lib()
290
291
  end = getattr(view, "period_end", None) or _command_as_of()
291
- start = getattr(view, "period_start", None) or end
292
+ view_start = getattr(view, "period_start", None)
293
+ start = view_start or end
292
294
  if end < start:
293
295
  start = end
294
- display_tz = getattr(view, "display_tz_label", "UTC") or "UTC"
296
+ # RESOLVED, not passed through (#503 S2 D7). The view's label comes
297
+ # from `_lib_view_models._codex_tz_label`, which returns whatever
298
+ # `--timezone` was given verbatim and otherwise falls back to
299
+ # `_local_tz_name()` — so it can carry a bare `UTC`, which is not a
300
+ # loadable ZoneInfo key on a case-sensitive filesystem, or a non-IANA
301
+ # abbreviation such as `IST`. Either would leave one artifact reading
302
+ # `(UTC)` beside a sibling reading `(Etc/UTC)`. Resolving here makes
303
+ # the label a concrete IANA zone whatever the view carried.
304
+ display_tz = resolve_display_tz_name(
305
+ getattr(view, "display_tz_label", None) or None)
306
+ # `period_start` on a bucketed Codex view is a CALENDAR LABEL — the
307
+ # oldest visible `YYYY-MM-DD` / `YYYY-MM` bucket, which
308
+ # `_lib_view_models` lifts with `datetime.combine(..., tzinfo=UTC)`.
309
+ # `period_end` is a real instant (`now_utc`), so the two boundaries
310
+ # are of different kinds and the per-period `civil_bucket` flag
311
+ # cannot express them. Ground the label at midnight in the zone the
312
+ # artifact names instead, which makes `period_civil_dates` recover
313
+ # the same bucket in every zone while the end still converts
314
+ # (#503 S2 D7). The VIEW declares which it carries through
315
+ # `period_civil_bucket`, because the value alone cannot say: an empty
316
+ # view's `period_start` is a real instant that happens to sit at
317
+ # whatever time "now" is, and `codex-session`'s is the earliest
318
+ # `last_activity`. A view without the field (the dashboard's adapter
319
+ # namespace, whose bounds are already grounded) is left alone.
320
+ if getattr(view, "period_civil_bucket", False) and view_start is not None:
321
+ start = c._share_ground_civil_date(
322
+ view_start.date(), _resolve_tz(display_tz, fallback=None))
323
+ if end < start:
324
+ start = end
295
325
  period_label = f"{start.date().isoformat()} → {end.date().isoformat()} ({display_tz})"
296
326
  titles = {
297
327
  "codex-daily": "Codex Token Usage — Daily",
@@ -339,7 +369,9 @@ def _build_codex_share_snapshot(command: str, view, rows):
339
369
  return lib.ShareSnapshot(
340
370
  cmd=command,
341
371
  title=titles[command],
342
- subtitle=period_label,
372
+ # #503 S2 D5 — the facts strip states this period, so a
373
+ # subtitle repeating it is duplicate chrome.
374
+ subtitle=None,
343
375
  period=lib.PeriodSpec(start=start, end=end, display_tz=display_tz, label=period_label),
344
376
  columns=columns,
345
377
  rows=table_rows,
@@ -332,6 +332,7 @@ ALLOWED_CONFIG_KEYS = (
332
332
  "budget.codex.accounts",
333
333
  "telemetry.enabled",
334
334
  "conversation.retention_days",
335
+ "storage.artifact_retention",
335
336
  "codex.hook.ingest_budget_seconds",
336
337
  )
337
338
 
@@ -389,6 +390,61 @@ def _validate_retention_days_value(raw: object) -> int:
389
390
  return value
390
391
 
391
392
 
393
+ def _validate_artifact_retention_value(raw: object) -> dict:
394
+ """Validate a ``config set`` value for ``storage.artifact_retention`` (#496 S6).
395
+
396
+ The argument is the raw CLI string (or an already-parsed object). JSON is
397
+ parsed here; every semantic rule is delegated to the kernel's
398
+ ``resolve_retention_policy`` so ``config set`` and the retention worker
399
+ share ONE predicate rather than drifting apart. Raises ``ValueError``,
400
+ which the caller maps to exit 2.
401
+ """
402
+ import _lib_artifact_retention as _ar
403
+
404
+ if isinstance(raw, str):
405
+ text = raw.strip()
406
+ if text.lower() in {"null", "none", ""}:
407
+ raise ValueError(
408
+ "storage.artifact_retention must be a JSON object "
409
+ "(use `config unset storage.artifact_retention` to restore "
410
+ "the defaults)"
411
+ )
412
+ try:
413
+ parsed: object = json.loads(text)
414
+ except (json.JSONDecodeError, ValueError):
415
+ raise ValueError(
416
+ "storage.artifact_retention must be a JSON object, got "
417
+ f"{raw!r}"
418
+ )
419
+ else:
420
+ parsed = raw
421
+ if not isinstance(parsed, dict):
422
+ raise ValueError("storage.artifact_retention must be a JSON object")
423
+ resolution = _ar.resolve_retention_policy(parsed)
424
+ if resolution.status == "malformed":
425
+ raise ValueError(resolution.reason)
426
+ return parsed
427
+
428
+
429
+ def resolve_artifact_retention_block(config: object) -> dict:
430
+ """The effective ``storage.artifact_retention`` block, in config units.
431
+
432
+ Read-only convenience for ``config get``: a malformed persisted block
433
+ surfaces the defaults here (mirroring every other ``_config_known_value``
434
+ branch) because a plain read must not error out. The DELETION path never
435
+ uses this — it calls ``resolve_retention_policy`` on a raw file read and
436
+ refuses to act on ``malformed`` (spec §6.5, C14).
437
+ """
438
+ import _lib_artifact_retention as _ar
439
+
440
+ storage = config.get("storage") if isinstance(config, dict) else None
441
+ raw = storage.get("artifact_retention") if isinstance(storage, dict) else None
442
+ resolution = _ar.resolve_retention_policy(raw)
443
+ if resolution.policy is None:
444
+ return _ar.default_policy_block()
445
+ return _ar.policy_to_block(resolution.policy)
446
+
447
+
392
448
  def _validate_codex_hook_ingest_budget_value(raw: object) -> float:
393
449
  """Validate a ``config set`` value for ``codex.hook.ingest_budget_seconds``.
394
450
 
@@ -1068,6 +1124,11 @@ def _config_known_value(config: dict, key: str) -> "object":
1068
1124
  # Effective transcript-retention window in days (default 180; 0 = keep
1069
1125
  # forever). Malformed persisted data surfaces the safe default (F8).
1070
1126
  return resolve_retention_days(config)
1127
+ if key == "storage.artifact_retention":
1128
+ # Effective retained-evidence policy in config units (#496 S6 §6.5).
1129
+ # Malformed persisted data surfaces the defaults on this READ path
1130
+ # only; the deletion path refuses to act on it instead.
1131
+ return resolve_artifact_retention_block(config)
1071
1132
  if key == "codex.hook.ingest_budget_seconds":
1072
1133
  # Wall-clock budget for the native Codex hook's ingest leg (public #5).
1073
1134
  # Malformed or out-of-range persisted data surfaces the default.
@@ -1172,7 +1233,7 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
1172
1233
  def _coerce(k: str, v: "object") -> "object":
1173
1234
  if k in (
1174
1235
  "alerts.command_template", "alerts.quota", "budget.projects",
1175
- "budget.accounts", "budget.codex",
1236
+ "budget.accounts", "budget.codex", "storage.artifact_retention",
1176
1237
  ) or k.startswith(_CODEX_BUDGET_LEAF_PREFIX):
1177
1238
  return v
1178
1239
  return v if v is not None else ""
@@ -1216,7 +1277,7 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
1216
1277
  # round-trips via `config set alerts.enabled <plain-text>` work.
1217
1278
  if k in (
1218
1279
  "alerts.command_template", "alerts.quota", "budget.projects",
1219
- "budget.accounts", "budget.codex",
1280
+ "budget.accounts", "budget.codex", "storage.artifact_retention",
1220
1281
  ):
1221
1282
  # JSON-encoded so `config get` output round-trips through the
1222
1283
  # matching `config set` branch (both JSON-parse their value).
@@ -1803,6 +1864,37 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
1803
1864
  else:
1804
1865
  print(f"{key}={normalized}")
1805
1866
  return 0
1867
+ if key == "storage.artifact_retention":
1868
+ # Validate first; rejection short-circuits before lock acquisition.
1869
+ # The whole block is stored verbatim (partial blocks inherit the
1870
+ # per-field defaults at resolve time), so an operator can write one
1871
+ # field without restating the rest.
1872
+ try:
1873
+ block_value = _validate_artifact_retention_value(raw)
1874
+ except ValueError as exc:
1875
+ print(f"cctally: {exc}", file=sys.stderr)
1876
+ return 2
1877
+ with config_writer_lock():
1878
+ config = _load_config_unlocked()
1879
+ existing_storage = config.get("storage")
1880
+ if existing_storage is not None and not isinstance(
1881
+ existing_storage, dict
1882
+ ):
1883
+ print(
1884
+ "cctally: storage config error: storage must be an object",
1885
+ file=sys.stderr,
1886
+ )
1887
+ return 2
1888
+ storage_block = dict(existing_storage or {})
1889
+ storage_block["artifact_retention"] = block_value
1890
+ config["storage"] = storage_block
1891
+ save_config(config)
1892
+ if getattr(args, "emit_json", False):
1893
+ print(json.dumps(
1894
+ {"storage": {"artifact_retention": block_value}}, indent=2))
1895
+ else:
1896
+ print(f"{key}={json.dumps(block_value, sort_keys=True)}")
1897
+ return 0
1806
1898
  if key in ("update.check.enabled", "update.check.ttl_hours"):
1807
1899
  # Validate first; rejection short-circuits before lock acquisition.
1808
1900
  if key == "update.check.enabled":
@@ -2354,6 +2446,20 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
2354
2446
  save_config(config)
2355
2447
  # idempotent: silent on missing key
2356
2448
  return 0
2449
+ if key == "storage.artifact_retention":
2450
+ # Mirror the conversation.retention_days branch: drop the leaf; if the
2451
+ # `storage` block ends up empty, drop it too. The next resolve returns
2452
+ # the whole DEFAULT_POLICY, not a partially-defaulted one.
2453
+ with config_writer_lock():
2454
+ config = _load_config_unlocked()
2455
+ block = config.get("storage")
2456
+ if isinstance(block, dict) and "artifact_retention" in block:
2457
+ del block["artifact_retention"]
2458
+ if not block:
2459
+ config.pop("storage", None)
2460
+ save_config(config)
2461
+ # idempotent: silent on missing key
2462
+ return 0
2357
2463
  if key in ("update.check.enabled", "update.check.ttl_hours"):
2358
2464
  # Mirror the dashboard.bind branch: drop the leaf, then prune
2359
2465
  # empty `check` and empty `update` so config.json stays tidy.
@@ -67,6 +67,7 @@ def _init_paths_from_env() -> None:
67
67
  global CONVERSATIONS_LOCK_MAINTENANCE_PATH
68
68
  global STATS_LOCK_MAINTENANCE_PATH
69
69
  global JOURNAL_DIR, JOURNAL_LOCK_PATH, JOURNAL_INGEST_LOCK_PATH
70
+ global ARTIFACT_RETENTION_LOCK_PATH
70
71
  global CONFIG_LOCK_PATH
71
72
  global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
72
73
  global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
@@ -142,6 +143,14 @@ def _init_paths_from_env() -> None:
142
143
  JOURNAL_DIR = APP_DIR / "journal"
143
144
  JOURNAL_LOCK_PATH = APP_DIR / "journal.lock"
144
145
  JOURNAL_INGEST_LOCK_PATH = APP_DIR / "journal.ingest.lock"
146
+ # Retained-evidence reclamation flock (#496 S6, spec §5.3). It enters the
147
+ # lock-order law AFTER the conversation provider flocks and BEFORE SQLite
148
+ # transactions, which keeps `journal.lock` the leaf. Every producer of
149
+ # retained evidence — corruption forensics, quarantine manifests, rebuild
150
+ # records, the backups `db repair` writes — holds it SHARED across the span
151
+ # in which its evidence is being published; the reclamation worker takes it
152
+ # EXCLUSIVE holding nothing earlier, marks, releases, and only then deletes.
153
+ ARTIFACT_RETENTION_LOCK_PATH = APP_DIR / "artifact-retention.lock"
145
154
  CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
146
155
 
147
156
  CONFIG_PATH = APP_DIR / "config.json"
@@ -390,7 +399,7 @@ LEGACY_STATS_HEAD = 13
390
399
  #: #496 S1 F1. A NEW branch, for a state that cannot occur before the
391
400
  #: publication transaction exists: a replacement index was published and then
392
401
  #: failed validation on a fresh connection. The existing corrupt-stats text
393
- #: says the database was "Not auto-recreated", which would be false here, so
402
+ #: says the database "is never auto-recreated", which would be false here, so
394
403
  #: this path gets its own wording. It does not alter the heal message or any
395
404
  #: other corruption path.
396
405
  STATS_PUBLICATION_FAILED_MSG = (
@@ -729,6 +738,14 @@ def ensure_dirs() -> None:
729
738
  os.chmod(APP_DIR, 0o700)
730
739
  except OSError as exc:
731
740
  eprint(f"[core] could not chmod data dir 0700 ({exc}); continuing")
741
+ # #496 S6 §9.4: LOG_DIR was created at the umask and was drwxr-xr-x, the
742
+ # same class of defect as the 0644 stats family. It holds corruption
743
+ # forensics bundles and rebuild records, which name paths and carry damage
744
+ # detail, so it gets the data directory's mode rather than the umask's.
745
+ try:
746
+ os.chmod(LOG_DIR, 0o700)
747
+ except OSError as exc:
748
+ eprint(f"[core] could not chmod log dir 0700 ({exc}); continuing")
732
749
 
733
750
 
734
751
  # === stats.db maintenance-hold tracking (#386) ======================
@@ -1652,9 +1669,13 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1652
1669
  # marker-only behaviour; see `_cctally_store.stats_open_guarded`.
1653
1670
  conn = _cctally_store.stats_open_guarded(db_path)
1654
1671
  conn.row_factory = sqlite3.Row
1655
- # #279 S1 F4: probe connect + initial PRAGMAs so a corrupt stats.db (the
1656
- # non-re-derivable DB) surfaces as a one-line diagnosis + staged exit 3 instead of
1657
- # a raw traceback. The catch boundary is DELIBERATELY narrow ONLY the
1672
+ # #279 S1 F4: probe connect + initial PRAGMAs so a corrupt stats.db
1673
+ # surfaces as a one-line diagnosis + staged exit 3 instead of a raw
1674
+ # traceback. With retained journal data stats.db is a disposable index and
1675
+ # the heal hook below rebuilds it; the guided error is for the pre-cutover
1676
+ # install with no retained journal data, whose stats.db may be the only
1677
+ # copy of its recorded history and is therefore never auto-recreated.
1678
+ # The catch boundary is DELIBERATELY narrow — ONLY the
1658
1679
  # connect/PRAGMA/probe below. The DDL + `_run_pending_migrations` region
1659
1680
  # further down is NOT wrapped: migration-handler failures have their own
1660
1681
  # logging/suppression contract and must not be mislabeled as corruption
@@ -1671,6 +1692,9 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1671
1692
  # StatsDbCorruptError boundary.
1672
1693
  _cctally_store.apply_policy(conn, "stats")
1673
1694
  conn.execute("SELECT 1").fetchone()
1695
+ # §9.2 (#496 S6 F23). AFTER `apply_policy`, because that is what sets
1696
+ # journal_mode=WAL and so what can materialize the sidecars.
1697
+ _cctally_store._harden_stats_family(db_path)
1674
1698
  except sqlite3.DatabaseError as exc:
1675
1699
  try:
1676
1700
  conn.close()
@@ -1699,6 +1723,7 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1699
1723
  try:
1700
1724
  _cctally_store.apply_policy(conn, "stats")
1701
1725
  conn.execute("SELECT 1").fetchone()
1726
+ _cctally_store._harden_stats_family(db_path)
1702
1727
  except sqlite3.DatabaseError as exc2:
1703
1728
  try:
1704
1729
  conn.close()
@@ -1713,10 +1738,14 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1713
1738
  else:
1714
1739
  raise c.StatsDbCorruptError(
1715
1740
  f"stats.db appears corrupt or unreadable ({exc}). path: {db_path}. "
1716
- f"Not auto-recreated it holds your recorded usage history. "
1717
- "Recovery: run `cctally db repair --db stats --yes`; it preserves "
1718
- "the corrupt original before replacing anything. Do not copy, "
1719
- "restore, or move the live DB by hand."
1741
+ "When journal data is retained for this install, stats.db is a "
1742
+ "disposable index and `cctally db rebuild --db stats` rebuilds "
1743
+ "it from the journal, losing nothing. On a pre-cutover install "
1744
+ "with no retained journal data it may be the only copy of your "
1745
+ "recorded history, so it is never auto-recreated: run `cctally "
1746
+ "db repair --db stats --yes`, which preserves the corrupt "
1747
+ "original before replacing anything. Do not copy, restore, or "
1748
+ "move the live DB by hand."
1720
1749
  ) from exc
1721
1750
 
1722
1751
  # ── stats.db epoch gate / in-place cutover (DB journal redesign §7.1/§8) ──
@@ -1238,6 +1238,7 @@ from _cctally_dashboard_share import (
1238
1238
  _handle_share_compose_post_impl,
1239
1239
  _handle_share_presets_get_impl,
1240
1240
  _handle_share_presets_post_impl,
1241
+ _handle_share_presets_rename_post_impl,
1241
1242
  _handle_share_presets_delete_impl,
1242
1243
  _handle_share_history_get_impl,
1243
1244
  _handle_share_history_post_impl,
@@ -4637,6 +4638,8 @@ _POST_ROUTES = (
4637
4638
  ("exact", "/api/share/render", "_handle_share_render_post", None, False),
4638
4639
  ("exact", "/api/share/compose", "_handle_share_compose_post", None, False),
4639
4640
  ("exact", "/api/share/presets", "_handle_share_presets_post", None, False),
4641
+ ("exact", "/api/share/presets/rename", "_handle_share_presets_rename_post",
4642
+ None, False),
4640
4643
  ("exact", "/api/share/history", "_handle_share_history_post", None, False),
4641
4644
  )
4642
4645
 
@@ -6087,6 +6090,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6087
6090
  def _handle_share_presets_post(self) -> None:
6088
6091
  return _handle_share_presets_post_impl(self)
6089
6092
 
6093
+ def _handle_share_presets_rename_post(self) -> None:
6094
+ return _handle_share_presets_rename_post_impl(self)
6095
+
6090
6096
  def _handle_share_presets_delete(self) -> None:
6091
6097
  return _handle_share_presets_delete_impl(self)
6092
6098