cctally 1.93.1 → 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.
@@ -33,6 +33,7 @@ cycle-free.
33
33
  """
34
34
  from __future__ import annotations
35
35
 
36
+ import dataclasses as _dataclasses
36
37
  import datetime as dt
37
38
  import json
38
39
  import pathlib
@@ -50,6 +51,7 @@ from _lib_fmt import stable_sum
50
51
  from _lib_pricing import _calculate_entry_cost, claude_usage_dict
51
52
  from _lib_five_hour import _canonical_5h_window_key
52
53
  from _lib_dashboard_sources import source_domain_freshness
54
+ from _lib_display_tz import _resolve_tz, resolve_display_tz_name
53
55
 
54
56
 
55
57
  # Share-CLI helpers consumed by the dashboard's share-data builders.
@@ -76,7 +78,8 @@ def _share_iso(*args, **kwargs):
76
78
  # #279 S1 F3: cap on /api/share/* POST bodies. The share composer sends bigger
77
79
  # payloads than the 4 KB settings POSTs (a multi-panel compose recipe), but
78
80
  # must still be bounded — 64 KiB comfortably exceeds any real payload (render is
79
- # one panel; compose is ~8 panels each with a small options recipe).
81
+ # one panel; compose is up to the basket cap of 20 sections, each with a
82
+ # small options recipe).
80
83
  _SHARE_POST_MAX_BYTES = 64 * 1024
81
84
 
82
85
 
@@ -1428,16 +1431,58 @@ def _share_codex_state_for_period(data_snap, *, panel: str, options: dict):
1428
1431
  stats_conn.close()
1429
1432
 
1430
1433
 
1431
- def _share_parse_bucket_start(panel: str, label: object) -> "dt.datetime | None":
1434
+ def _share_resolved_display_tz(raw: object = None) -> str:
1435
+ """The concrete IANA zone a share artifact states its dates in.
1436
+
1437
+ One resolution for the whole dashboard share surface (#503 S2 D7):
1438
+ the render handler injects it into `options`, the composite handler
1439
+ injects it into every composite section's options, and the Codex
1440
+ snapshot builders read it back instead of hardcoding `"UTC"` — so a
1441
+ composed document cannot carry one section labelled `(UTC)` beside
1442
+ another labelled `(Etc/UTC)`.
1443
+
1444
+ `raw` is a configuration token (`local` / `utc` / IANA) or None; None
1445
+ means "read the server's config".
1446
+ """
1447
+ c = sys.modules["cctally"]
1448
+ token = raw if isinstance(raw, str) and raw else c.get_display_tz_pref(c.load_config())
1449
+ return resolve_display_tz_name(token)
1450
+
1451
+
1452
+ def _share_parse_bucket_start(panel: str, label: object,
1453
+ zone=None) -> "dt.datetime | None":
1454
+ """Lift a bucket label or a row timestamp into a period boundary.
1455
+
1456
+ A NAIVE parse is a calendar label (`2026-05-04`, `2026-05`), so it is
1457
+ grounded at midnight in the zone the artifact is labelled with —
1458
+ grounding it in UTC and then converting it into that zone reports the
1459
+ previous day everywhere west of UTC (#503 S2 D7). An AWARE parse
1460
+ (`first_seen` / `last_activity`, which the envelope serializes with an
1461
+ explicit offset) is a real instant and keeps its own offset.
1462
+ """
1432
1463
  try:
1433
1464
  if panel == "monthly":
1434
- return dt.datetime.strptime(str(label), "%Y-%m").replace(tzinfo=dt.timezone.utc)
1435
- return dt.datetime.fromisoformat(str(label)).replace(tzinfo=dt.timezone.utc)
1465
+ parsed = dt.datetime.strptime(str(label), "%Y-%m")
1466
+ else:
1467
+ parsed = dt.datetime.fromisoformat(str(label))
1436
1468
  except ValueError:
1437
1469
  return None
1470
+ if parsed.tzinfo is None:
1471
+ return parsed.replace(tzinfo=zone or dt.timezone.utc)
1472
+ return parsed
1473
+
1438
1474
 
1475
+ def _share_codex_period_bounds(*, state, panel: str, options: dict, rows,
1476
+ display_tz: "str | None" = None) -> tuple:
1477
+ """The period bounds for a Codex source snapshot.
1439
1478
 
1440
- def _share_codex_period_bounds(*, state, panel: str, options: dict, rows) -> tuple:
1479
+ `display_tz` is the zone the SNAPSHOT will be labelled with, and a
1480
+ bucket label is grounded at ITS midnight (#503 S2 D7). The period
1481
+ families resolve that from the panel's own value before calling
1482
+ here, and that can differ from the options-derived default —
1483
+ grounding in one zone while labelling with another states a date
1484
+ the artifact's own rows do not use.
1485
+ """
1441
1486
  now_override, start_override, err = _share_resolve_period(panel, options)
1442
1487
  if err is not None:
1443
1488
  raise ValueError("source capability unavailable")
@@ -1447,23 +1492,82 @@ def _share_codex_period_bounds(*, state, panel: str, options: dict, rows) -> tup
1447
1492
  end = end.astimezone(dt.timezone.utc)
1448
1493
  if start_override is not None:
1449
1494
  return start_override.astimezone(dt.timezone.utc), end
1495
+ zone = _resolve_tz(
1496
+ display_tz or _share_resolved_display_tz(options.get("display_tz")),
1497
+ fallback=dt.timezone.utc)
1450
1498
  starts = []
1451
1499
  for row in rows:
1452
1500
  if not isinstance(row, Mapping):
1453
1501
  continue
1454
1502
  raw = row.get("first_seen") or row.get("last_activity") or row.get("label")
1455
- parsed = _share_parse_bucket_start("monthly" if panel == "monthly" else "daily", raw)
1503
+ parsed = _share_parse_bucket_start(
1504
+ "monthly" if panel == "monthly" else "daily", raw, zone)
1456
1505
  if parsed is not None:
1457
1506
  starts.append(parsed)
1458
1507
  if panel == "current-week":
1459
1508
  starts = [
1460
1509
  parsed for row in rows if isinstance(row, Mapping)
1461
- if (parsed := _share_parse_bucket_start("weekly", row.get("label"))) is not None
1510
+ if (parsed := _share_parse_bucket_start(
1511
+ "weekly", row.get("label"), zone)) is not None
1462
1512
  ]
1463
1513
  return (max(starts) if starts else end - dt.timedelta(days=7)), end
1464
1514
  return (min(starts) if starts else end), end
1465
1515
 
1466
1516
 
1517
+ # What the block column states when the row carries no parseable start.
1518
+ _CODEX_BLOCK_LABEL_UNKNOWN = "(unknown)"
1519
+
1520
+
1521
+ def _codex_block_label(ls, row) -> str:
1522
+ """The block-start text a Codex quota artifact states.
1523
+
1524
+ `bin/_cctally_dashboard_sources.py` renders each block's `label` as
1525
+ `%H:%M %b %d` for the dashboard chip, where the compact form is the
1526
+ point and the year is context the surrounding page supplies. An
1527
+ artifact leaves cctally, so it names its year: every Claude blocks
1528
+ artifact states a full ISO instant, and this column stated the chip
1529
+ string instead (#503 S2 M5, the sixth site of the D4 class).
1530
+
1531
+ The absent-`start_at` fallback does NOT reach for `label`. That field
1532
+ is only ever the yearless chip, so falling back to it re-introduced
1533
+ the exact string the fix removed — and neither D4 tripwire could see
1534
+ it, because one scans the six share-builder modules (which do not
1535
+ include `_cctally_dashboard_sources.py`, where the chip is formatted)
1536
+ and the other scans committed goldens (no golden covers this panel).
1537
+ A row with no parseable start falls back to its own `resets_at`,
1538
+ stated as a full ISO instant and labelled as a reset so the column is
1539
+ not read as a start. `(unknown)` discarded information the row still
1540
+ carried and left the block unidentifiable in the artifact; it remains
1541
+ only for a row that carries neither field (#503 S2 second review N4,
1542
+ third review).
1543
+ """
1544
+ for field, prefix in (("start_at", ""), ("resets_at", "resets ")):
1545
+ rendered = _codex_block_instant(ls, row, field)
1546
+ if rendered is not None:
1547
+ return prefix + rendered
1548
+ return _CODEX_BLOCK_LABEL_UNKNOWN
1549
+
1550
+
1551
+ def _codex_block_instant(ls, row, field: str) -> "str | None":
1552
+ """One of the row's instants as `…Z`, or None when it has none."""
1553
+ raw = row.get(field)
1554
+ if raw:
1555
+ try:
1556
+ parsed = parse_iso_datetime(str(raw), f"codex.block.{field}")
1557
+ except ValueError:
1558
+ pass
1559
+ else:
1560
+ # NORMALIZED to UTC before formatting. `parse_iso_datetime`
1561
+ # ends with a bare `astimezone()`, so it hands back a
1562
+ # host-local datetime, and `_format_generated_at_iso` keeps
1563
+ # whatever offset it is given — the cell would otherwise read
1564
+ # `+03:00` on one machine and `Z` on another, where every
1565
+ # other blocks artifact states `…Z`.
1566
+ return ls._format_generated_at_iso(
1567
+ parsed.astimezone(dt.timezone.utc))
1568
+ return None
1569
+
1570
+
1467
1571
  def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1468
1572
  template_id: str, options: dict):
1469
1573
  """Adapt S4 normalized data through canonical Codex share kernels."""
@@ -1494,7 +1598,13 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1494
1598
  all_rows = tuple(weekly.get("rows", ()))
1495
1599
  source_rows = all_rows[-1:] if all_rows else ()
1496
1600
  command = "codex-weekly"
1497
- display_tz = str(weekly.get("display_tz") or "UTC")
1601
+ # RESOLVED, never passed through (#503 S2 review F9). The panel
1602
+ # value is a display label, and `_lib_view_models._display_tz_label`
1603
+ # returns the literal `local` for a `None` zone — so a caller that
1604
+ # omits `options["display_tz"]` would put `(local)` back into an
1605
+ # artifact, which D7 exists to prevent.
1606
+ display_tz = _share_resolved_display_tz(
1607
+ weekly.get("display_tz") or options.get("display_tz"))
1498
1608
  elif panel in ("daily", "monthly", "weekly", "trend"):
1499
1609
  periods = data.get("periods")
1500
1610
  period_key = "weekly" if panel == "trend" else panel
@@ -1503,7 +1613,9 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1503
1613
  raise ValueError("source capability unavailable")
1504
1614
  source_rows = tuple(panel_data.get("rows", ()))
1505
1615
  command = f"codex-{period_key}"
1506
- display_tz = str(panel_data.get("display_tz") or "UTC")
1616
+ # Resolved, never passed through — see the `current-week` branch.
1617
+ display_tz = _share_resolved_display_tz(
1618
+ panel_data.get("display_tz") or options.get("display_tz"))
1507
1619
  elif panel == "forecast":
1508
1620
  quota = data.get("quota")
1509
1621
  panel_data = quota if isinstance(quota, Mapping) else {}
@@ -1534,7 +1646,10 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1534
1646
  }))
1535
1647
  return ls.ShareSnapshot(
1536
1648
  cmd="codex-quota", title="Codex Quota Forecast", subtitle=None,
1537
- period=ls.PeriodSpec(start=start, end=end, display_tz="UTC", label=None),
1649
+ period=ls.PeriodSpec(
1650
+ start=start, end=end, label=None,
1651
+ display_tz=_share_resolved_display_tz(options.get("display_tz")),
1652
+ ),
1538
1653
  columns=(
1539
1654
  ls.ColumnSpec(key="limit", label="Limit"),
1540
1655
  ls.ColumnSpec(key="current", label="Current", align="right"),
@@ -1549,7 +1664,7 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1549
1664
  panel_data = data.get(panel) if isinstance(data.get(panel), Mapping) else {}
1550
1665
  source_rows = tuple(panel_data.get("rows", ())) if isinstance(panel_data, Mapping) else ()
1551
1666
  command = "codex-session"
1552
- display_tz = "UTC"
1667
+ display_tz = _share_resolved_display_tz(options.get("display_tz"))
1553
1668
  elif panel == "projects":
1554
1669
  panel_data = data.get("projects") if isinstance(data.get("projects"), Mapping) else {}
1555
1670
  source_rows = tuple(panel_data.get("rows", ())) if isinstance(panel_data, Mapping) else ()
@@ -1567,7 +1682,10 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1567
1682
  }) for row in source_rows if isinstance(row, Mapping))
1568
1683
  return ls.ShareSnapshot(
1569
1684
  cmd="project", title="Codex Project Usage", subtitle=None,
1570
- period=ls.PeriodSpec(start=start, end=end, display_tz="UTC", label=None),
1685
+ period=ls.PeriodSpec(
1686
+ start=start, end=end, label=None,
1687
+ display_tz=_share_resolved_display_tz(options.get("display_tz")),
1688
+ ),
1571
1689
  columns=(
1572
1690
  ls.ColumnSpec(key="project", label="Project"),
1573
1691
  ls.ColumnSpec(key="tokens", label="Tokens", align="right"),
@@ -1579,7 +1697,15 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1579
1697
  template_id=template_id, source="codex", source_label="Codex",
1580
1698
  availability=availability, availability_reason=reason,
1581
1699
  )
1582
- else: # blocks
1700
+ elif panel == "blocks":
1701
+ # NAMED rather than left as the chain's `else`. The three
1702
+ # `PeriodSpec` sites in this function are addressed BY ORDINAL
1703
+ # from the test suite, and an unnamed branch cannot be attributed
1704
+ # to a panel from the source, so the driver's ordinal-to-panel
1705
+ # mapping was unassertable (#503 S2 third review). Naming it also
1706
+ # turns an unrecognised panel into an error rather than silently
1707
+ # rendering it as a blocks artifact; `required_domain` above
1708
+ # already rejects every panel this chain does not list.
1583
1709
  quota = data.get("quota")
1584
1710
  panel_data = quota if isinstance(quota, Mapping) else {}
1585
1711
  source_rows = tuple(panel_data.get("blocks", ())) if isinstance(panel_data, Mapping) else ()
@@ -1594,23 +1720,29 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1594
1720
  def cells(row):
1595
1721
  percent = row.get("current_percent", 0.0)
1596
1722
  return {
1597
- "label": ls.TextCell(str(row.get("label", "Codex quota"))),
1723
+ "label": ls.TextCell(_codex_block_label(ls, row)),
1598
1724
  "usage": ls.TextCell(f"{float(percent or 0.0):.1f}%"),
1599
1725
  "resets": ls.TextCell(str(row.get("resets_at", "—"))),
1600
1726
  }
1601
1727
  rows = tuple(ls.Row(cells=cells(row)) for row in source_rows if isinstance(row, Mapping))
1602
1728
  return ls.ShareSnapshot(
1603
1729
  cmd="codex-quota", title="Codex Quota Windows", subtitle=None,
1604
- period=ls.PeriodSpec(start=start, end=end, display_tz="UTC", label=None),
1730
+ period=ls.PeriodSpec(
1731
+ start=start, end=end, label=None,
1732
+ display_tz=_share_resolved_display_tz(options.get("display_tz")),
1733
+ ),
1605
1734
  columns=columns, rows=rows, chart=None,
1606
1735
  totals=(), notes=(), generated_at=end,
1607
1736
  version=sys.modules["cctally"]._share_resolve_version(),
1608
1737
  template_id=template_id, source="codex", source_label="Codex",
1609
1738
  availability=availability, availability_reason=reason,
1610
1739
  )
1740
+ else:
1741
+ raise ValueError(f"unsupported codex source panel: {panel}")
1611
1742
 
1612
1743
  start, end = _share_codex_period_bounds(
1613
1744
  state=state, panel=panel, options=options, rows=source_rows,
1745
+ display_tz=display_tz,
1614
1746
  )
1615
1747
  normalized_rows = tuple(
1616
1748
  SimpleNamespace(
@@ -1637,27 +1769,6 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1637
1769
  )
1638
1770
 
1639
1771
 
1640
- def _share_plain_value(value):
1641
- if isinstance(value, Mapping):
1642
- return {str(key): _share_plain_value(item) for key, item in value.items()}
1643
- if isinstance(value, (tuple, list)):
1644
- return [_share_plain_value(item) for item in value]
1645
- if isinstance(value, dt.datetime):
1646
- return value.astimezone(dt.timezone.utc).isoformat()
1647
- return value
1648
-
1649
-
1650
- def _share_state_domain(state, panel: str):
1651
- data = state.data if isinstance(state.data, Mapping) else {}
1652
- if panel in ("daily", "monthly", "weekly", "current-week"):
1653
- periods = data.get("periods") if isinstance(data.get("periods"), Mapping) else {}
1654
- key = "weekly" if panel == "current-week" else panel
1655
- return periods.get(key)
1656
- if panel == "blocks":
1657
- return data.get("quota")
1658
- return data.get(panel)
1659
-
1660
-
1661
1772
  def _share_apply_current_week_freshness(snapshot, state, panel: str):
1662
1773
  """Qualify retained current-week actuals with provider-local evidence age."""
1663
1774
  if (
@@ -1672,46 +1783,124 @@ def _share_apply_current_week_freshness(snapshot, state, panel: str):
1672
1783
  return replace(snapshot, notes=tuple(snapshot.notes) + (note,))
1673
1784
 
1674
1785
 
1786
+ # The meaning of `data_digest`, as a stored value (#503 S3 §4).
1787
+ #
1788
+ # `data_digest_at_add` lives in localStorage basket items and is replayed at
1789
+ # compose time, so redefining what the digest hashes would mark every stored
1790
+ # section outdated exactly once. Version 2 is that redefinition: two digests
1791
+ # are compared ONLY when the stored version equals this one. A missing or
1792
+ # older version is NOT COMPARABLE, which is not drifted — no badge, no third
1793
+ # badge state. `KERNEL_VERSION` is untouched: it versions the RENDERER
1794
+ # contract (`_lib_share.py`), which has not changed.
1795
+ _SHARE_DATA_DIGEST_VERSION = 2
1796
+
1797
+
1798
+ def _share_digest_value(ls, value):
1799
+ """Structurally project one value for `_data_digest` (#503 S3 §4).
1800
+
1801
+ `_data_digest` serializes with `default=str`, and its own contract warns
1802
+ that arbitrary objects then hash as a per-process-unstable `repr`. The
1803
+ projected snapshot fields are nested frozen dataclasses — cells, `Row`,
1804
+ `ColumnSpec`, the chart union, chart points — so handing them over
1805
+ directly is exactly that hazard. This converts them instead:
1806
+
1807
+ - a `PeriodSpec` becomes its CIVIL dates plus zone and label, never its
1808
+ raw `start`/`end` instants (`period_civil_dates` already honours S2's
1809
+ `civil_bucket` discriminator, so a `daily` bucket is not shifted a day
1810
+ west of UTC);
1811
+ - any other dataclass becomes a mapping of its declared fields, carrying
1812
+ `__type__` so the cell and chart unions stay discriminated (a
1813
+ `TextCell("5")` must not hash as a `DateCell("5")`);
1814
+ - mappings recurse structurally and tuples/lists preserve order;
1815
+ - a `datetime` outside a `PeriodSpec` becomes a normalized UTC ISO string;
1816
+ - anything else RAISES, so the callers' empty-digest fallback stays
1817
+ defensive rather than silently hashing a `repr`.
1818
+ """
1819
+ if value is None or isinstance(value, (str, bool, int, float)):
1820
+ return value
1821
+ if isinstance(value, ls.PeriodSpec):
1822
+ start_civil, end_civil = ls.period_civil_dates(value)
1823
+ return {
1824
+ "__type__": "PeriodSpec",
1825
+ "civil": [start_civil, end_civil],
1826
+ "display_tz": value.display_tz,
1827
+ "label": value.label,
1828
+ }
1829
+ if _dataclasses.is_dataclass(value) and not isinstance(value, type):
1830
+ projected = {"__type__": type(value).__name__}
1831
+ for field in _dataclasses.fields(value):
1832
+ projected[field.name] = _share_digest_value(
1833
+ ls, getattr(value, field.name))
1834
+ return projected
1835
+ if isinstance(value, Mapping):
1836
+ return {str(key): _share_digest_value(ls, item)
1837
+ for key, item in value.items()}
1838
+ if isinstance(value, (tuple, list)):
1839
+ return [_share_digest_value(ls, item) for item in value]
1840
+ if isinstance(value, dt.datetime):
1841
+ return value.astimezone(dt.timezone.utc).isoformat()
1842
+ raise TypeError(
1843
+ f"share digest cannot serialize {type(value).__name__}")
1844
+
1845
+
1846
+ def _share_snapshot_digest_projection(ls, snapshot):
1847
+ """The canonical projection of ONE built, pre-toggle `ShareSnapshot`.
1848
+
1849
+ Two snapshot fields are excluded BY NAME. `generated_at` is a wall clock,
1850
+ and hashing a wall clock is what made every section drift with elapsed
1851
+ time. `version` is a renderer concern `KERNEL_VERSION` already covers.
1852
+ Nothing from the ambient process state enters — no `data_version`, no raw
1853
+ provider domain, no clock.
1854
+ """
1855
+ return {
1856
+ "title": snapshot.title,
1857
+ "subtitle": snapshot.subtitle,
1858
+ "period": _share_digest_value(ls, snapshot.period),
1859
+ "columns": _share_digest_value(ls, snapshot.columns),
1860
+ "rows": _share_digest_value(ls, snapshot.rows),
1861
+ "chart": _share_digest_value(ls, snapshot.chart),
1862
+ "totals": _share_digest_value(ls, snapshot.totals),
1863
+ "notes": _share_digest_value(ls, snapshot.notes),
1864
+ "template_id": snapshot.template_id,
1865
+ "source": snapshot.source,
1866
+ "source_label": snapshot.source_label,
1867
+ "availability": snapshot.availability,
1868
+ "availability_reason": snapshot.availability_reason,
1869
+ }
1870
+
1871
+
1675
1872
  def _share_digest_input(*, panel: str, template_id: str, source: str,
1676
- source_explicit: bool, states, snapshots,
1677
- panel_data, account: "str | None" = None):
1873
+ snapshots, account: "str | None" = None, ls=None):
1874
+ """The digest payload: a projection of the built snapshots, and nothing else.
1875
+
1876
+ `snapshots` MUST be the PRE-toggle tuple. `_share_apply_content_toggles`
1877
+ strips `chart` and `columns`/`rows` per `show_chart`/`show_table`, and a
1878
+ Codex `blocks` section carries its window identity ONLY in its rows — its
1879
+ title and period label are constants — so hashing the toggled tuple
1880
+ collapses two different five-hour windows on one civil day into one
1881
+ digest. Hashing pre-toggle also makes both toggles genuinely render-only,
1882
+ which is what the render handler's comment has always promised.
1883
+
1884
+ One definition for every request: version 1 forked on `source_explicit`
1885
+ and kept a separate legacy payload, which left that branch carrying the
1886
+ defect this rewrite removes. `_SHARE_DATA_DIGEST_VERSION` is how a stored
1887
+ digest's meaning moves; see `docs/share-gotchas.md`.
1888
+ """
1889
+ ls = ls if ls is not None else _share_load_lib()
1678
1890
  # #341 Task 4: the captured account participates in the digest so switching
1679
1891
  # the focused account registers as data drift in the composer (spec §4).
1680
- # Absent (legacy / account-agnostic) → omitted, so the digest is byte-stable.
1892
+ # It stays TOP-LEVEL rather than being read off the snapshots: an
1893
+ # account-focus change must change identity even when two scoped snapshots
1894
+ # happen to hold equal values. Absent → omitted.
1681
1895
  account_key = {"account": account} if account is not None else {}
1682
- if not source_explicit:
1683
- return {
1684
- "panel": panel,
1685
- "template_id": template_id,
1686
- "panel_data": panel_data,
1687
- **account_key,
1688
- }
1689
- providers = []
1690
- for state, snapshot in zip(states, snapshots):
1691
- providers.append({
1692
- "source": state.source,
1693
- "data_version": state.data_version,
1694
- "availability": state.availability,
1695
- **(
1696
- {"hero_freshness": source_domain_freshness(state, "hero")}
1697
- if panel == "current-week" else {}
1698
- ),
1699
- "period": {
1700
- "start": snapshot.period.start,
1701
- "end": snapshot.period.end,
1702
- "display_tz": snapshot.period.display_tz,
1703
- },
1704
- "data": _share_plain_value(_share_state_domain(state, panel)),
1705
- })
1706
1896
  return {
1707
1897
  "panel": panel,
1708
1898
  "template_id": template_id,
1709
1899
  "source": source,
1710
- "providers": providers,
1711
- **(
1712
- {"claude_panel_data": panel_data}
1713
- if source in ("claude", "all") else {}
1714
- ),
1900
+ "snapshots": [
1901
+ _share_snapshot_digest_projection(ls, snapshot)
1902
+ for snapshot in snapshots
1903
+ ],
1715
1904
  **account_key,
1716
1905
  }
1717
1906
 
@@ -1953,8 +2142,14 @@ def _handle_share_render_post_impl(handler) -> None:
1953
2142
  # truth. Inject before `_share_apply_period_override` so the
1954
2143
  # daily panel rebuild and per-day cross-tab bucketing both see
1955
2144
  # the user's display tz instead of falling back to UTC.
1956
- if "display_tz" not in options:
1957
- options["display_tz"] = sys.modules["cctally"].get_display_tz_pref(sys.modules["cctally"].load_config())
2145
+ #
2146
+ # RESOLVED here, once (#503 S2 D7). `get_display_tz_pref` returns a
2147
+ # configuration TOKEN whose default is the literal `local`, and that
2148
+ # token used to travel all the way into `PeriodSpec.display_tz`, so
2149
+ # the artifact stated a zone that names no zone. The resolution is
2150
+ # unconditional rather than gated on the key being absent, because a
2151
+ # caller-supplied token is a token too.
2152
+ options["display_tz"] = _share_resolved_display_tz(options.get("display_tz"))
1958
2153
  if not isinstance(panel, str) or not panel:
1959
2154
  handler._respond_json(400, {
1960
2155
  "error": "missing or non-string panel",
@@ -2023,7 +2218,7 @@ def _handle_share_render_post_impl(handler) -> None:
2023
2218
  data_snap = snap_ref.get() if snap_ref is not None else None
2024
2219
  ls = _share_load_lib()
2025
2220
  try:
2026
- source_snaps, source_states, panel_data = _share_build_source_snapshots(
2221
+ source_snaps, _source_states, _panel_data = _share_build_source_snapshots(
2027
2222
  ls=ls,
2028
2223
  template=template,
2029
2224
  template_id=template_id,
@@ -2045,6 +2240,11 @@ def _handle_share_render_post_impl(handler) -> None:
2045
2240
  except Exception as exc:
2046
2241
  _share_public_failure(handler, exc, phase="render provider")
2047
2242
  return
2243
+ # TWO tuples, and the order is the whole point (#503 S3 §4). The digest
2244
+ # hashes what the BUILDERS produced; `render()` receives the toggled
2245
+ # versions. Reassigning `source_snaps` first — which is what this site
2246
+ # did — hands the digest a snapshot whose rows a render knob erased.
2247
+ digest_snaps = source_snaps
2048
2248
  source_snaps = tuple(
2049
2249
  _share_apply_content_toggles(item, options) for item in source_snaps
2050
2250
  )
@@ -2089,24 +2289,27 @@ def _handle_share_render_post_impl(handler) -> None:
2089
2289
  "svg": "image/svg+xml",
2090
2290
  }[fmt]
2091
2291
 
2092
- # data_digest hashes the inputs that identify the underlying DATA
2093
- # (panel + template + panel_data), NOT rendering toggles like theme
2094
- # / branding / reveal_projects / format. Used by the composer to
2095
- # detect "section data has drifted since add-time" (spec §5.2 /
2096
- # §7.1) — flipping anon-on-export must not register as drift, since
2097
- # the underlying data is identical.
2098
- digest_input = _share_digest_input(
2099
- panel=panel,
2100
- template_id=template_id,
2101
- source=source,
2102
- source_explicit=source_explicit,
2103
- states=source_states,
2104
- snapshots=source_snaps,
2105
- panel_data=panel_data,
2106
- account=account,
2107
- )
2292
+ # data_digest hashes a canonical projection of the BUILT, PRE-TOGGLE
2293
+ # snapshots — what the artifact is made of — and nothing else. NOT the
2294
+ # rendering toggles (theme / branding / reveal_projects / format /
2295
+ # show_chart / show_table), and NOT the wall clock or the raw provider
2296
+ # state. Used by the composer to detect "section data has drifted since
2297
+ # add-time" (spec §5.2 / §7.1); flipping anon-on-export must not register
2298
+ # as drift, since the underlying data is identical.
2108
2299
  try:
2109
- data_digest = ls._data_digest(digest_input)
2300
+ # The projection is INSIDE the guard, matching the compose site.
2301
+ # `_share_digest_value` raises on a value it cannot serialize, and
2302
+ # `do_POST` has no exception guard of its own, so building the input
2303
+ # outside this `try` turned a projection failure into a dropped
2304
+ # connection instead of the empty digest the fallback promises.
2305
+ data_digest = ls._data_digest(_share_digest_input(
2306
+ panel=panel,
2307
+ template_id=template_id,
2308
+ source=source,
2309
+ snapshots=digest_snaps,
2310
+ account=account,
2311
+ ls=ls,
2312
+ ))
2110
2313
  except Exception:
2111
2314
  # Defensive: digest is non-blocking for the response — fall
2112
2315
  # back to an empty string and let the composer treat it as
@@ -2155,6 +2358,7 @@ def _handle_share_render_post_impl(handler) -> None:
2155
2358
  "options": options,
2156
2359
  "generated_at": _share_now_utc_iso(),
2157
2360
  "data_digest": data_digest,
2361
+ "data_digest_version": _SHARE_DATA_DIGEST_VERSION,
2158
2362
  **({"source": source} if source_explicit else {}),
2159
2363
  **account_meta,
2160
2364
  },
@@ -2226,8 +2430,9 @@ def _handle_share_compose_post_impl(handler) -> None:
2226
2430
  # Resolve display_tz from config once (client `ShareOptions`
2227
2431
  # does not carry it); applied to every section's options below
2228
2432
  # so daily panel rebuilds and per-day cross-tab cells bucket in
2229
- # the user's display tz, not UTC.
2230
- composite_display_tz = sys.modules["cctally"].get_display_tz_pref(sys.modules["cctally"].load_config())
2433
+ # the user's display tz, not UTC. Resolved to a CONCRETE IANA zone
2434
+ # (#503 S2 D7) so no section states the token `local` as its zone.
2435
+ composite_display_tz = _share_resolved_display_tz()
2231
2436
 
2232
2437
  composed_sections: list = []
2233
2438
  section_results: list[dict] = []
@@ -2297,9 +2502,15 @@ def _handle_share_compose_post_impl(handler) -> None:
2297
2502
  composite_opts = {**sec_opts, "reveal_projects": reveal_projects,
2298
2503
  "theme": theme, "format": fmt,
2299
2504
  "no_branding": no_branding}
2300
- composite_opts.setdefault("display_tz", composite_display_tz)
2505
+ # Not `setdefault`: a section that arrived carrying its own
2506
+ # `display_tz` is carrying a TOKEN, which must be resolved too.
2507
+ composite_opts["display_tz"] = (
2508
+ _share_resolved_display_tz(sec_opts["display_tz"])
2509
+ if isinstance(sec_opts.get("display_tz"), str) and sec_opts["display_tz"]
2510
+ else composite_display_tz
2511
+ )
2301
2512
  try:
2302
- source_snaps, source_states, panel_data = _share_build_source_snapshots(
2513
+ source_snaps, _source_states, _panel_data = _share_build_source_snapshots(
2303
2514
  ls=ls,
2304
2515
  template=template,
2305
2516
  template_id=template_id,
@@ -2326,10 +2537,13 @@ def _handle_share_compose_post_impl(handler) -> None:
2326
2537
  handler, exc, phase=f"compose section {idx} provider",
2327
2538
  )
2328
2539
  return
2329
- # Same content toggles as the single-section render path.
2540
+ # Same content toggles as the single-section render path, and the
2541
+ # same two-tuple ordering (#503 S3 §4): `digest_snaps` is what the
2542
+ # builders produced, `source_snaps` is what `compose()` renders.
2330
2543
  # Per-section `show_chart`/`show_table` from the basket
2331
2544
  # recipe are applied here; the composite anon flag is
2332
2545
  # already merged into composite_opts upstream.
2546
+ digest_snaps = source_snaps
2333
2547
  source_snaps = tuple(
2334
2548
  _share_apply_content_toggles(item, composite_opts)
2335
2549
  for item in source_snaps
@@ -2338,10 +2552,14 @@ def _handle_share_compose_post_impl(handler) -> None:
2338
2552
  # under one merged alias namespace. Scrubbing per section here is what
2339
2553
  # made `project-1` denote a different project in each section.
2340
2554
  #
2341
- # The digest below is unaffected by the removal: `_share_digest_input`
2342
- # reads only `snapshot.period` off each snapshot, never a label, so it
2343
- # stays byte-identical and no basket section spuriously reads
2344
- # "Outdated".
2555
+ # The digest below is unaffected by the removal, and the reason has
2556
+ # changed with version 2. `_share_digest_input` now reads `title`,
2557
+ # `subtitle`, `columns`, `rows`, `chart`, `totals` and `notes` off each
2558
+ # snapshot, labels included — so the claim can no longer rest on "it
2559
+ # never reads a label". It rests on ORDER instead: the digest hashes
2560
+ # `digest_snaps`, the snapshots the builders produced, and every
2561
+ # anonymization happens later inside `compose()`. Nothing scrubbed can
2562
+ # reach the digest, so no basket section spuriously reads "Outdated".
2345
2563
 
2346
2564
  # Defensive: digest is non-blocking metadata — fall back to
2347
2565
  # "" on failure rather than 500-ing the whole compose
@@ -2351,29 +2569,42 @@ def _handle_share_compose_post_impl(handler) -> None:
2351
2569
  panel=panel,
2352
2570
  template_id=template_id,
2353
2571
  source=source,
2354
- source_explicit=source_explicit,
2355
- states=source_states,
2356
- snapshots=source_snaps,
2357
- panel_data=panel_data,
2572
+ snapshots=digest_snaps,
2573
+ ls=ls,
2358
2574
  # #341 Task 4: carry the section's captured account so a
2359
2575
  # focus-changed section re-digests as drift (matching render).
2360
- account=_share_account_selection(snap_recipe),
2576
+ account=section_account,
2361
2577
  ))
2362
2578
  except Exception:
2363
2579
  digest_now = ""
2580
+ # Two digests are comparable only when they mean the same thing
2581
+ # (#503 S3 §4). A section stored before `_SHARE_DATA_DIGEST_VERSION`
2582
+ # existed, or under an older one, is NOT COMPARABLE — and not
2583
+ # comparable is not drifted, so it carries no badge rather than a
2584
+ # spurious "Outdated" the user cannot clear. An absent field is the
2585
+ # legacy case and reads as not comparable, which is the fail-safe
2586
+ # direction: it under-reports drift once instead of over-reporting it
2587
+ # for every stored section.
2588
+ digest_comparable = (
2589
+ snap_recipe.get("data_digest_version_at_add")
2590
+ == _SHARE_DATA_DIGEST_VERSION
2591
+ )
2592
+ drift_detected = digest_comparable and digest_now != digest_at_add
2364
2593
  composed_sections.extend(
2365
- ls.ComposedSection(
2366
- snap=item,
2367
- drift_detected=(digest_now != digest_at_add),
2368
- )
2594
+ ls.ComposedSection(snap=item, drift_detected=drift_detected)
2369
2595
  for item in source_snaps
2370
2596
  )
2371
2597
  section_results.append({
2372
2598
  "snapshot_id": f"{idx:02d}",
2373
2599
  "source": source,
2374
- "drift_detected": digest_now != digest_at_add,
2600
+ "drift_detected": drift_detected,
2375
2601
  "data_digest_at_add": digest_at_add,
2376
2602
  "data_digest_now": digest_now,
2603
+ # ADDITIVE (docs/cli-contract.md): a consumer that does not know
2604
+ # this key keeps reading `drift_detected`, which is already
2605
+ # false whenever this is false.
2606
+ "digest_comparable": digest_comparable,
2607
+ "data_digest_version": _SHARE_DATA_DIGEST_VERSION,
2377
2608
  })
2378
2609
 
2379
2610
  compose_opts = ls.ComposeOptions(
@@ -2529,21 +2760,164 @@ def _handle_share_presets_post_impl(handler) -> None:
2529
2760
  })
2530
2761
  return
2531
2762
 
2763
+ # #503 S3 §1. Absent means false, which is the fail-safe direction and is
2764
+ # what makes this compatible with a caller written before the field
2765
+ # existed: an unwitting save can no longer destroy a stored recipe.
2766
+ overwrite = bool(req.get("overwrite", False))
2767
+
2532
2768
  saved_at = _share_now_utc_iso()
2533
2769
  record = {
2534
2770
  "template_id": template_id, "options": options,
2535
2771
  "source": source, "saved_at": saved_at,
2536
2772
  }
2537
2773
 
2774
+ # The OUTCOME is decided under the lock; the RESPONSE is written after it.
2775
+ # `config_writer_lock` is a cross-process `fcntl.flock`, and a client that
2776
+ # reads its socket slowly would otherwise hold it for the length of that
2777
+ # write, blocking every other config writer in every other process.
2778
+ conflict = False
2538
2779
  with sys.modules["cctally"].config_writer_lock():
2539
2780
  cfg = _load_config_unlocked()
2540
2781
  share = cfg.setdefault("share", {})
2541
2782
  presets = share.setdefault("presets", {})
2542
2783
  panel_bucket = presets.setdefault(panel, {})
2543
- panel_bucket[name] = record
2544
- save_config(cfg)
2784
+ # Decided INSIDE the writer lock (spec §1): a client-side name-list
2785
+ # preflight can go stale between its GET and this write, so the
2786
+ # preflight is an optimisation and this is the authority. Nothing is
2787
+ # persisted on this branch — `save_config` is the only writer.
2788
+ if name in panel_bucket and not overwrite:
2789
+ conflict = True
2790
+ else:
2791
+ panel_bucket[name] = record
2792
+ save_config(cfg)
2793
+ if conflict:
2794
+ handler._respond_json(409, _SHARE_PRESET_CONFLICT("name", name))
2795
+ return
2545
2796
  handler._respond_json(200, {"panel": panel, "name": name, **record})
2546
2797
 
2798
+
2799
+ # The one conflict body both preset mutations answer with (spec §1). Stable
2800
+ # machine-readable `code`, the offending field, and a message that names only
2801
+ # the preset the caller already sent.
2802
+ def _SHARE_PRESET_CONFLICT(field: str, name: str) -> dict:
2803
+ return {
2804
+ "code": "preset_name_conflict",
2805
+ "error": f"a preset named {name!r} already exists",
2806
+ "field": field,
2807
+ }
2808
+
2809
+
2810
+ def _share_preset_name_error(field: str) -> dict:
2811
+ return {
2812
+ "error": "name must be 1-64 chars and contain no '/'",
2813
+ "field": field,
2814
+ }
2815
+
2816
+
2817
+ def _handle_share_presets_rename_post_impl(handler) -> None:
2818
+ """Rename a preset atomically, keeping its identity (#503 S3 §1).
2819
+
2820
+ Body: ``{panel, from_name, to_name, overwrite}``. CSRF-gated.
2821
+
2822
+ Rename was not an operation: the client issued ``savePreset`` then
2823
+ ``deletePreset``, rebuilding the record from the four fields it happened
2824
+ to hold. That dropped ``source`` (the server defaults it to ``claude``,
2825
+ so a renamed Codex preset started showing a Claude chip), reset
2826
+ ``saved_at`` even though the recipe had not changed, and silently
2827
+ overwrote any preset already holding the target name.
2828
+
2829
+ So this MOVES THE STORED RECORD WHOLE — ``bucket[to] = bucket.pop(from)``
2830
+ — under ONE ``config_writer_lock`` with one ``save_config``. Nothing
2831
+ reconstructs the record, so every field it carries survives, including
2832
+ fields added later.
2833
+
2834
+ A self-rename is rejected explicitly: a move-then-delete on one key
2835
+ deletes the record, and the client-side guard is not sufficient because
2836
+ this endpoint is independently reachable.
2837
+
2838
+ Write discipline is the presets POST's: ``config_writer_lock`` +
2839
+ ``_load_config_unlocked`` + ``save_config``. Never ``load_config`` inside
2840
+ the lock — ``fcntl.flock`` is per-fd and self-deadlocks.
2841
+ """
2842
+ if not handler._check_origin_csrf():
2843
+ return
2844
+ try:
2845
+ length = int(handler.headers.get("Content-Length", "0") or "0")
2846
+ except ValueError:
2847
+ length = 0
2848
+ if length > _SHARE_POST_MAX_BYTES:
2849
+ handler._respond_json(400, {"error": "body too large (max 64 KiB)"})
2850
+ return
2851
+ try:
2852
+ raw = handler.rfile.read(length) if length > 0 else b""
2853
+ req = json.loads(raw) if raw else {}
2854
+ except (ValueError, json.JSONDecodeError):
2855
+ handler._respond_json(400, {"error": "malformed json"})
2856
+ return
2857
+ if not isinstance(req, dict):
2858
+ handler._respond_json(400, {"error": "expected JSON object"})
2859
+ return
2860
+
2861
+ panel = req.get("panel")
2862
+ from_name = req.get("from_name")
2863
+ to_name = req.get("to_name")
2864
+ overwrite = bool(req.get("overwrite", False))
2865
+
2866
+ if not isinstance(panel, str) or not panel:
2867
+ handler._respond_json(400, {
2868
+ "error": "missing or non-string panel",
2869
+ "field": "panel",
2870
+ })
2871
+ return
2872
+ tpl_mod = handler._share_load_templates_module()
2873
+ if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
2874
+ handler._respond_json(400, {
2875
+ "error": f"unknown share panel: {panel!r}",
2876
+ "field": "panel",
2877
+ })
2878
+ return
2879
+ for value, field in ((from_name, "from_name"), (to_name, "to_name")):
2880
+ if (not isinstance(value, str) or not value
2881
+ or "/" in value or len(value) > 64):
2882
+ handler._respond_json(400, _share_preset_name_error(field))
2883
+ return
2884
+ if from_name == to_name:
2885
+ handler._respond_json(400, {
2886
+ "error": "from_name and to_name are the same preset",
2887
+ "field": "to_name",
2888
+ })
2889
+ return
2890
+
2891
+ # Outcome decided under the lock, response written after it — the same
2892
+ # rule the save handler above states, for the same reason.
2893
+ outcome = "ok"
2894
+ record = None
2895
+ with sys.modules["cctally"].config_writer_lock():
2896
+ cfg = _load_config_unlocked()
2897
+ share = cfg.get("share") or {}
2898
+ presets = share.get("presets") or {}
2899
+ panel_bucket = presets.get(panel) or {}
2900
+ if from_name not in panel_bucket:
2901
+ outcome = "missing"
2902
+ # The target is checked under the SAME lock that performs the move,
2903
+ # so two renames racing onto one name cannot both win.
2904
+ elif to_name in panel_bucket and not overwrite:
2905
+ outcome = "conflict"
2906
+ else:
2907
+ record = panel_bucket.pop(from_name)
2908
+ panel_bucket[to_name] = record
2909
+ save_config(cfg)
2910
+ if outcome == "missing":
2911
+ handler._respond_json(404, {"error": "no such preset"})
2912
+ return
2913
+ if outcome == "conflict":
2914
+ handler._respond_json(409, _SHARE_PRESET_CONFLICT("to_name", to_name))
2915
+ return
2916
+ handler._respond_json(200, {
2917
+ "panel": panel, "name": to_name,
2918
+ **(record if isinstance(record, dict) else {"record": record}),
2919
+ })
2920
+
2547
2921
  def _handle_share_presets_delete_impl(handler) -> None:
2548
2922
  """Remove a preset by `(panel, name)`.
2549
2923
 
@@ -2570,19 +2944,25 @@ def _handle_share_presets_delete_impl(handler) -> None:
2570
2944
  return
2571
2945
  panel = _urlparse.unquote(parts[4])
2572
2946
  name = _urlparse.unquote(parts[5])
2947
+ # Outcome decided under the lock, response written after it — the same
2948
+ # rule the save and rename handlers state, for the same reason.
2949
+ missing = False
2573
2950
  with sys.modules["cctally"].config_writer_lock():
2574
2951
  cfg = _load_config_unlocked()
2575
2952
  share = cfg.get("share") or {}
2576
2953
  presets = share.get("presets") or {}
2577
2954
  panel_bucket = presets.get(panel) or {}
2578
2955
  if name not in panel_bucket:
2579
- handler._respond_json(404, {"error": "no such preset"})
2580
- return
2581
- del panel_bucket[name]
2582
- # Tidy empty buckets so GET stays clean.
2583
- if not panel_bucket:
2584
- presets.pop(panel, None)
2585
- save_config(cfg)
2956
+ missing = True
2957
+ else:
2958
+ del panel_bucket[name]
2959
+ # Tidy empty buckets so GET stays clean.
2960
+ if not panel_bucket:
2961
+ presets.pop(panel, None)
2962
+ save_config(cfg)
2963
+ if missing:
2964
+ handler._respond_json(404, {"error": "no such preset"})
2965
+ return
2586
2966
  handler.send_response(204)
2587
2967
  handler.send_header("Content-Length", "0")
2588
2968
  handler.end_headers()
@@ -2600,14 +2980,148 @@ def _handle_share_presets_delete_impl(handler) -> None:
2600
2980
  # `_check_origin_csrf`. The frontend posts fire-and-forget after
2601
2981
  # every successful export — history failures are non-fatal.
2602
2982
 
2983
+ # #503 S3 §3 — a history row is a discriminated union on `kind`.
2984
+ #
2985
+ # `"panel"` is every field the row has always carried. `"composed"` is one
2986
+ # multi-section export: `panel: null`, a bounded `sections[]`, and the
2987
+ # composite knobs. There is NO migration and none is needed: a missing `kind`
2988
+ # READS as `"panel"` (normalized in the GET response only, never written
2989
+ # back), and a composed row's `panel: null` makes an older client's
2990
+ # `h.panel === panel` filter false, so it hides the row rather than
2991
+ # mis-rendering it. `docs/share-gotchas.md` records that `share.*` keys need
2992
+ # no formal migration; this shape keeps that true.
2993
+ _SHARE_HISTORY_KINDS = ("panel", "composed")
2994
+
2995
+ # Twenty is the composer basket cap, so a composed row is bounded by
2996
+ # construction — this is the server saying so rather than trusting it.
2997
+ _SHARE_HISTORY_COMPOSED_MAX_SECTIONS = 20
2998
+
2999
+ # The fields that belong to exactly one branch. A row carrying the other
3000
+ # branch's fields is rejected rather than silently half-read.
3001
+ _SHARE_HISTORY_PANEL_ONLY_FIELDS = ("template_id", "options", "source",
3002
+ "account")
3003
+ _SHARE_HISTORY_COMPOSED_ONLY_FIELDS = ("sections", "composite")
3004
+
3005
+
3006
+ class _ShareHistoryError(ValueError):
3007
+ """Carry a 400 envelope out of the history validators."""
3008
+
3009
+ def __init__(self, payload: Mapping):
3010
+ super().__init__(str(payload.get("error", "invalid history row")))
3011
+ self.payload = dict(payload)
3012
+
3013
+
3014
+ def _share_history_read_kind(record) -> str:
3015
+ """The branch a STORED row belongs to. Absent is the legacy panel row."""
3016
+ if not isinstance(record, Mapping):
3017
+ return "panel"
3018
+ kind = record.get("kind")
3019
+ return kind if kind in _SHARE_HISTORY_KINDS else "panel"
3020
+
3021
+
3022
+ def _share_history_normalize_record(record):
3023
+ """The read-side shape of one stored row (response only, never written).
3024
+
3025
+ A panel row keeps its S4 `source` default; a composed row has no
3026
+ top-level source to default, and inventing one would put a provider
3027
+ label on a document that has one per section.
3028
+ """
3029
+ if not isinstance(record, Mapping):
3030
+ return record
3031
+ kind = _share_history_read_kind(record)
3032
+ if kind == "composed":
3033
+ return {**record, "kind": "composed", "panel": record.get("panel")}
3034
+ return {**record, "kind": "panel",
3035
+ "source": record.get("source", "claude")}
3036
+
3037
+
3038
+ def _share_history_validate_section(tpl_mod, sec, idx: int) -> dict:
3039
+ """One composed section, held to the same invariants a panel row is."""
3040
+ field = f"sections[{idx}]"
3041
+ if not isinstance(sec, Mapping):
3042
+ raise _ShareHistoryError({
3043
+ "error": f"{field} must be an object", "field": field})
3044
+ panel = sec.get("panel")
3045
+ template_id = sec.get("template_id")
3046
+ options = sec.get("options")
3047
+ if options is None:
3048
+ options = {}
3049
+ if not isinstance(panel, str) or panel not in tpl_mod.SHARE_CAPABLE_PANELS:
3050
+ raise _ShareHistoryError({
3051
+ "error": f"unknown share panel: {panel!r}",
3052
+ "field": f"{field}.panel"})
3053
+ if not isinstance(template_id, str) or not template_id:
3054
+ raise _ShareHistoryError({
3055
+ "error": "missing or non-string template_id",
3056
+ "field": f"{field}.template_id"})
3057
+ try:
3058
+ template = tpl_mod.get_template(template_id)
3059
+ except KeyError:
3060
+ raise _ShareHistoryError({
3061
+ "error": f"unknown template_id: {template_id!r}",
3062
+ "field": f"{field}.template_id"}) from None
3063
+ if template.panel != panel:
3064
+ raise _ShareHistoryError({
3065
+ "error": (f"template_id {template_id!r} belongs to panel "
3066
+ f"{template.panel!r}, not {panel!r}"),
3067
+ "field": f"{field}.template_id"})
3068
+ if not isinstance(options, Mapping):
3069
+ raise _ShareHistoryError({
3070
+ "error": "options must be an object",
3071
+ "field": f"{field}.options"})
3072
+ try:
3073
+ source, _ = _share_source_selection(dict(sec))
3074
+ except ValueError:
3075
+ raise _ShareHistoryError({
3076
+ "code": "source_capability_unavailable",
3077
+ "error": "source capability unavailable",
3078
+ "field": f"{field}.source"}) from None
3079
+ try:
3080
+ account = _share_account_selection(dict(sec))
3081
+ except ValueError:
3082
+ raise _ShareHistoryError({
3083
+ "error": "malformed account key",
3084
+ "field": f"{field}.account"}) from None
3085
+ return {
3086
+ "panel": panel, "template_id": template_id,
3087
+ "options": dict(options), "source": source,
3088
+ **({"account": account} if account is not None else {}),
3089
+ }
3090
+
3091
+
3092
+ def _share_history_validate_composite(composite) -> dict:
3093
+ """The composite knobs a composed row states about the document."""
3094
+ if composite is None:
3095
+ composite = {}
3096
+ if not isinstance(composite, Mapping):
3097
+ raise _ShareHistoryError({
3098
+ "error": "composite must be an object", "field": "composite"})
3099
+ title = composite.get("title")
3100
+ if not isinstance(title, str) or not title or len(title) > 200:
3101
+ raise _ShareHistoryError({
3102
+ "error": "composite.title must be 1-200 chars",
3103
+ "field": "composite.title"})
3104
+ theme = composite.get("theme", "light")
3105
+ if theme not in ("light", "dark"):
3106
+ raise _ShareHistoryError({
3107
+ "error": f"unknown theme: {theme!r}", "field": "composite.theme"})
3108
+ knobs = {}
3109
+ for key in ("reveal_projects", "no_branding"):
3110
+ value = composite.get(key, False)
3111
+ if not isinstance(value, bool):
3112
+ raise _ShareHistoryError({
3113
+ "error": f"composite.{key} must be a boolean",
3114
+ "field": f"composite.{key}"})
3115
+ knobs[key] = value
3116
+ return {"title": title, "theme": theme, **knobs}
3117
+
3118
+
2603
3119
  def _handle_share_history_get_impl(handler) -> None:
2604
3120
  """Return the recent-shares ring buffer (newest last, spec §11.4)."""
2605
3121
  cfg = sys.modules["cctally"].load_config()
2606
3122
  history = (cfg.get("share") or {}).get("history") or []
2607
3123
  handler._respond_json(200, {"history": [
2608
- ({**record, "source": record.get("source", "claude")}
2609
- if isinstance(record, dict) else record)
2610
- for record in history
3124
+ _share_history_normalize_record(record) for record in history
2611
3125
  ]})
2612
3126
 
2613
3127
  def _handle_share_history_post_impl(handler) -> None:
@@ -2639,11 +3153,37 @@ def _handle_share_history_post_impl(handler) -> None:
2639
3153
  if not isinstance(req, dict):
2640
3154
  handler._respond_json(400, {"error": "expected JSON object"})
2641
3155
  return
3156
+ # #503 S3 §3. An absent `kind` is the legacy panel row and validates
3157
+ # exactly as it always has; an unknown value is refused rather than
3158
+ # silently filed as one of the two branches.
3159
+ kind = req.get("kind", "panel")
3160
+ if kind not in _SHARE_HISTORY_KINDS:
3161
+ handler._respond_json(400, {
3162
+ "error": f"unknown history kind: {kind!r}",
3163
+ "field": "kind",
3164
+ })
3165
+ return
3166
+ tpl_mod = handler._share_load_templates_module()
3167
+ if kind == "composed":
3168
+ try:
3169
+ record = _share_history_composed_record(tpl_mod, req)
3170
+ except _ShareHistoryError as exc:
3171
+ handler._respond_json(400, exc.payload)
3172
+ return
3173
+ _share_history_append(handler, record)
3174
+ return
3175
+ for field in _SHARE_HISTORY_COMPOSED_ONLY_FIELDS:
3176
+ if field in req:
3177
+ handler._respond_json(400, {
3178
+ "error": f"{field} belongs to a composed row",
3179
+ "field": field,
3180
+ })
3181
+ return
2642
3182
  panel = req.get("panel")
2643
3183
  template_id = req.get("template_id")
2644
3184
  options = req.get("options") or {}
2645
- fmt = req.get("format")
2646
- destination = req.get("destination")
3185
+ # `fmt` and `destination` are read below from
3186
+ # `_share_history_advisory_strings(req)`, which validates them.
2647
3187
  try:
2648
3188
  source, _ = _share_source_selection(req)
2649
3189
  account = _share_account_selection(req)
@@ -2659,7 +3199,6 @@ def _handle_share_history_post_impl(handler) -> None:
2659
3199
  "field": "panel",
2660
3200
  })
2661
3201
  return
2662
- tpl_mod = handler._share_load_templates_module()
2663
3202
  if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
2664
3203
  handler._respond_json(400, {
2665
3204
  "error": f"unknown share panel: {panel!r}",
@@ -2695,25 +3234,15 @@ def _handle_share_history_post_impl(handler) -> None:
2695
3234
  "field": "options",
2696
3235
  })
2697
3236
  return
2698
- # `format` and `destination` are advisory strings — accept any
2699
- # non-empty string; the frontend uses them only as display hints
2700
- # in the dropdown row. None/missing is allowed (mirrors how the
2701
- # CLI doesn't always know which destination produced the export).
2702
- if fmt is not None and not isinstance(fmt, str):
2703
- handler._respond_json(400, {
2704
- "error": "format must be a string if provided",
2705
- "field": "format",
2706
- })
2707
- return
2708
- if destination is not None and not isinstance(destination, str):
2709
- handler._respond_json(400, {
2710
- "error": "destination must be a string if provided",
2711
- "field": "destination",
2712
- })
3237
+ try:
3238
+ fmt, destination = _share_history_advisory_strings(req)
3239
+ except _ShareHistoryError as exc:
3240
+ handler._respond_json(400, exc.payload)
2713
3241
  return
2714
3242
 
2715
3243
  record = {
2716
3244
  "recipe_id": _share_history_recipe_id(),
3245
+ "kind": "panel",
2717
3246
  "panel": panel,
2718
3247
  "template_id": template_id,
2719
3248
  "options": options,
@@ -2723,6 +3252,84 @@ def _handle_share_history_post_impl(handler) -> None:
2723
3252
  "destination": destination,
2724
3253
  "exported_at": _share_now_utc_iso(),
2725
3254
  }
3255
+ _share_history_append(handler, record)
3256
+
3257
+
3258
+ def _share_history_advisory_strings(req: Mapping) -> tuple:
3259
+ """`format` and `destination` — display hints, not contracts.
3260
+
3261
+ Any non-empty string is accepted; the frontend uses them only as row
3262
+ labels in the dropdown. None/missing is allowed (mirrors how the CLI
3263
+ doesn't always know which destination produced the export).
3264
+ """
3265
+ values = []
3266
+ for field in ("format", "destination"):
3267
+ value = req.get(field)
3268
+ if value is not None and not isinstance(value, str):
3269
+ raise _ShareHistoryError({
3270
+ "error": f"{field} must be a string if provided",
3271
+ "field": field,
3272
+ })
3273
+ values.append(value)
3274
+ return tuple(values)
3275
+
3276
+
3277
+ def _share_history_composed_record(tpl_mod, req: Mapping) -> dict:
3278
+ """Validate and build ONE composed history row (#503 S3 §3).
3279
+
3280
+ Every section is held to exactly the invariants a panel row is — panel
3281
+ membership, template ownership, options shape, source and account —
3282
+ applied per section, because a section that would 400 on replay is the
3283
+ same poisoned dropdown row a bad panel row would be.
3284
+ """
3285
+ for field in _SHARE_HISTORY_PANEL_ONLY_FIELDS:
3286
+ if field in req:
3287
+ raise _ShareHistoryError({
3288
+ "error": f"{field} belongs to a panel row",
3289
+ "field": field,
3290
+ })
3291
+ panel = req.get("panel")
3292
+ if panel is not None:
3293
+ raise _ShareHistoryError({
3294
+ "error": "a composed row carries no panel",
3295
+ "field": "panel",
3296
+ })
3297
+ sections_in = req.get("sections")
3298
+ if (not isinstance(sections_in, list) or not sections_in
3299
+ or len(sections_in) > _SHARE_HISTORY_COMPOSED_MAX_SECTIONS):
3300
+ raise _ShareHistoryError({
3301
+ "error": (
3302
+ "sections must hold 1-"
3303
+ f"{_SHARE_HISTORY_COMPOSED_MAX_SECTIONS} entries"
3304
+ ),
3305
+ "field": "sections",
3306
+ })
3307
+ sections = [
3308
+ _share_history_validate_section(tpl_mod, sec, idx)
3309
+ for idx, sec in enumerate(sections_in)
3310
+ ]
3311
+ composite = _share_history_validate_composite(req.get("composite"))
3312
+ fmt, destination = _share_history_advisory_strings(req)
3313
+ return {
3314
+ "recipe_id": _share_history_recipe_id(),
3315
+ "kind": "composed",
3316
+ # EXPLICIT null, not an absent key: it is what makes an older
3317
+ # client's `h.panel === panel` filter hide this row.
3318
+ "panel": None,
3319
+ "sections": sections,
3320
+ "composite": composite,
3321
+ "format": fmt,
3322
+ "destination": destination,
3323
+ "exported_at": _share_now_utc_iso(),
3324
+ }
3325
+
3326
+
3327
+ def _share_history_append(handler, record: dict) -> None:
3328
+ """Append one row to the ring buffer and answer with it.
3329
+
3330
+ Write discipline matches the presets handlers: `config_writer_lock` +
3331
+ `_load_config_unlocked` + `save_config`.
3332
+ """
2726
3333
  with sys.modules["cctally"].config_writer_lock():
2727
3334
  cfg = _load_config_unlocked()
2728
3335
  share = cfg.setdefault("share", {})