cctally 1.95.4 → 1.96.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.
@@ -397,6 +397,17 @@ from _lib_dashboard_json import (
397
397
  encode_dashboard_json,
398
398
  encode_dashboard_json_bytes,
399
399
  )
400
+ # #513 S1: the one map that says which settings leaves this endpoint writes,
401
+ # which it accepts and ignores, and which it rejects. Pure and import-free, so
402
+ # tests/test_config_documentation.py can read the same contract without
403
+ # loading the dashboard.
404
+ from _lib_dashboard_settings_contract import (
405
+ KNOWN_IGNORED as _SETTINGS_KNOWN_IGNORED,
406
+ SETTINGS_LEAF_DISPOSITIONS,
407
+ SETTINGS_OBJECT_PATHS,
408
+ SETTINGS_REQUIRED_LEAVES,
409
+ SETTINGS_TOP_LEVEL_BLOCKS,
410
+ )
400
411
  from _cctally_config import save_config, _load_config_unlocked
401
412
  from _cctally_db import _render_migration_error_banner
402
413
  from _cctally_cache import (
@@ -1559,6 +1570,120 @@ def _config_known_value(*args, **kwargs):
1559
1570
  return sys.modules["cctally"]._config_known_value(*args, **kwargs)
1560
1571
 
1561
1572
 
1573
+ # === POST /api/settings path classification (#513 S1, spec §1.3) =========
1574
+ # One rule for the whole endpoint: every submitted terminal path is looked up
1575
+ # in SETTINGS_LEAF_DISPOSITIONS. A writable leaf is validated and merged, a
1576
+ # known-ignored leaf is accepted and disclosed on the echo, and anything else
1577
+ # is a 400 naming that dotted path. This replaces the per-block unknown-key
1578
+ # loops that used to carry the same knowledge in five separate tuples.
1579
+
1580
+ #: The six blocks in the order the "at least one of" message names them. The
1581
+ #: SET is asserted against the contract by
1582
+ #: tests/test_config_settings_dispositions.py; the ORDER is presentation.
1583
+ _SETTINGS_BLOCK_ORDER = (
1584
+ "display", "alerts", "update", "cache_report", "budget", "dashboard",
1585
+ )
1586
+
1587
+ #: Leaves that are refused for a specific, explainable reason rather than
1588
+ #: because nobody has heard of them. The classification adds a field pointer
1589
+ #: to these messages and changes nothing else -- a generic "unknown settings
1590
+ #: key" would be a worse answer than the sentence already written here.
1591
+ _SETTINGS_PURPOSE_WRITTEN_REJECTIONS = {
1592
+ "alerts.command_template": (
1593
+ "alerts.command_template is CLI/config-only "
1594
+ "(not settable via the dashboard)"
1595
+ ),
1596
+ "dashboard.bind": (
1597
+ "dashboard.bind is not settable via the dashboard "
1598
+ "(bind-time / privacy-gate setting)"
1599
+ ),
1600
+ "dashboard.expose_transcripts": (
1601
+ "dashboard.expose_transcripts is not settable via the dashboard "
1602
+ "(bind-time / privacy-gate setting)"
1603
+ ),
1604
+ }
1605
+
1606
+ #: Unknown-key wording that shipped before the classification existed, keyed
1607
+ #: by the parent object path. Kept because harness cases pin these exact
1608
+ #: strings; paths without an entry get the generic dotted form.
1609
+ _SETTINGS_LEGACY_UNKNOWN_MESSAGES = {
1610
+ "update": "unknown update settings key: {leaf}",
1611
+ "update.check": "unknown update.check key: {leaf}",
1612
+ "dashboard": "unknown dashboard settings key: {leaf}",
1613
+ }
1614
+
1615
+
1616
+ def _settings_unknown_message(path: str) -> str:
1617
+ """The 400 message for a submitted path this endpoint does not accept."""
1618
+ purpose_written = _SETTINGS_PURPOSE_WRITTEN_REJECTIONS.get(path)
1619
+ if purpose_written is not None:
1620
+ return purpose_written
1621
+ parent, _, leaf = path.rpartition(".")
1622
+ legacy = _SETTINGS_LEGACY_UNKNOWN_MESSAGES.get(parent)
1623
+ if legacy is not None:
1624
+ return legacy.format(leaf=leaf)
1625
+ return f"unknown settings key: {path}"
1626
+
1627
+
1628
+ def _classify_settings_payload(payload: dict) -> tuple:
1629
+ """Classify every terminal path in a settings body.
1630
+
1631
+ Returns ``(rejection, ignored_fields)``. ``rejection`` is ``None`` or the
1632
+ ``(message, field)`` pair for the FIRST unacceptable path, walked in the
1633
+ request's own key order so the pointer is stable for a given body.
1634
+ ``ignored_fields`` is the sorted list of known-ignored paths present.
1635
+
1636
+ A non-dict value sitting at an interior path is deliberately NOT a
1637
+ finding here: that is a structural failure, and the block's own
1638
+ "must be an object" check owns it and words it better.
1639
+ """
1640
+ ignored: list[str] = []
1641
+ rejection: "tuple[str, str] | None" = None
1642
+
1643
+ def walk(path: str, value: object) -> None:
1644
+ nonlocal rejection
1645
+ if rejection is not None:
1646
+ return
1647
+ disposition = SETTINGS_LEAF_DISPOSITIONS.get(path)
1648
+ if disposition is not None:
1649
+ if disposition == _SETTINGS_KNOWN_IGNORED:
1650
+ ignored.append(path)
1651
+ return
1652
+ if path in SETTINGS_OBJECT_PATHS:
1653
+ if isinstance(value, dict):
1654
+ for leaf, sub_value in value.items():
1655
+ walk(f"{path}.{leaf}", sub_value)
1656
+ return
1657
+ rejection = (_settings_unknown_message(path), path)
1658
+
1659
+ for block, block_value in payload.items():
1660
+ walk(block, block_value)
1661
+ return rejection, sorted(ignored)
1662
+
1663
+
1664
+ def _settings_missing_required_leaf(payload: dict) -> "str | None":
1665
+ """Return the first required leaf a named block failed to supply.
1666
+
1667
+ The emptiness rule operates on BLOCKS, not leaves: a named block carrying
1668
+ no leaves is an ordinary partial-PUT no-op, which is what
1669
+ ``{"cache_report": {}}`` relies on when a combined save's UI never opened
1670
+ that tab. ``display`` is the one block that declares a required leaf.
1671
+ """
1672
+ for block, required in SETTINGS_REQUIRED_LEAVES.items():
1673
+ if block not in payload:
1674
+ continue
1675
+ block_value = payload[block]
1676
+ present = (
1677
+ {f"{block}.{leaf}" for leaf in block_value}
1678
+ if isinstance(block_value, dict)
1679
+ else set()
1680
+ )
1681
+ missing = sorted(required - present)
1682
+ if missing:
1683
+ return missing[0]
1684
+ return None
1685
+
1686
+
1562
1687
  def config_writer_lock(*args, **kwargs):
1563
1688
  return sys.modules["cctally"].config_writer_lock(*args, **kwargs)
1564
1689
 
@@ -5177,6 +5302,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5177
5302
  — every top-level key is optional; any subset may be sent together
5178
5303
  (combined save). Unknown top-level keys are rejected with 400.
5179
5304
 
5305
+ One classification rule (#513 S1). Every submitted terminal path is
5306
+ looked up in ``SETTINGS_LEAF_DISPOSITIONS``
5307
+ (``bin/_lib_dashboard_settings_contract.py``). A writable leaf is
5308
+ validated and merged; a known-ignored leaf is accepted, not
5309
+ persisted, and disclosed on the echo via ``ignored_fields``; anything
5310
+ else is a 400 naming that dotted path in ``field``. Classification
5311
+ runs BEFORE the per-block validators and before the ``budget.codex``
5312
+ fail-closed guard, so an unknown leaf under ``budget.codex`` reports
5313
+ itself rather than the prerequisite.
5314
+
5315
+ The emptiness rule operates on BLOCKS, not leaves: the body must name
5316
+ at least one known block, but a named block carrying no leaves is an
5317
+ ordinary partial-PUT no-op that still echoes its cooked block.
5318
+ ``{"cache_report": {}}`` depends on this — it is what a combined save
5319
+ sends when the user never opened that tab. ``display`` is the one
5320
+ block declaring a required leaf (``SETTINGS_REQUIRED_LEAVES``), so
5321
+ ``{"display": {}}`` stays a 400.
5322
+
5323
+ Every 400 carries a ``field``. Whole-document failures (over-cap
5324
+ body, zero-length body, malformed JSON, non-object payload, no known
5325
+ block) use ``"$"``; everything else names a block or a dotted leaf.
5326
+
5180
5327
  Per-block validation:
5181
5328
  * ``display.tz`` — "local", "utc", or a valid IANA zone (via
5182
5329
  ``normalize_display_tz_value``); 400 on invalid.
@@ -5192,9 +5339,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5192
5339
  would silently accept ``true`` for a numeric field).
5193
5340
  * ``cache_report.anomaly_threshold_pp`` — JSON int (NOT bool /
5194
5341
  float / string), in ``[1, 100]``; 400 with
5195
- ``{error, field: "anomaly_threshold_pp"}`` on out-of-range
5196
- or non-int. Spec §6.1 hardcodes ``anomaly_window_days``;
5197
- F10 tracks lifting that.
5342
+ ``{error, field: "cache_report.anomaly_threshold_pp"}`` on
5343
+ out-of-range or non-int. The validator raises the bare leaf
5344
+ name and this handler qualifies it, so the response carries
5345
+ the same dotted form every other rejection uses. Spec §6.1
5346
+ hardcodes ``anomaly_window_days``; F10 tracks lifting that.
5198
5347
  * ``budget`` — must be a dict; the inbound leaves
5199
5348
  (``weekly_usd`` / ``alerts_enabled`` / ``alert_thresholds`` /
5200
5349
  ``projected_enabled``) are merged onto the persisted ``budget``
@@ -5242,6 +5391,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5242
5391
  / bearer tokens — and the echo is returned to the client; the
5243
5392
  SSE ``alerts_settings`` mirror redacts identically). Do NOT
5244
5393
  re-add the raw template to the echo.
5394
+ ``ignored_fields`` — a sorted array of the known-ignored dotted
5395
+ paths this request carried — is present only when that set is
5396
+ non-empty, which is what keeps the field additive for clients
5397
+ written before it existed.
5245
5398
  ``saved_at`` is included for backward compat.
5246
5399
  """
5247
5400
  if not self._check_origin_csrf():
@@ -5251,44 +5404,59 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5251
5404
  length = int(self.headers.get("Content-Length", "0") or "0")
5252
5405
  except ValueError:
5253
5406
  length = 0
5407
+ # Whole-document failures name "$" as their field: there is no leaf
5408
+ # to point at when the body itself is the problem.
5254
5409
  if length <= 0 or length > 4096:
5255
- self._respond_json(400, {"error": "body required (<=4 KB)"})
5410
+ self._respond_json(
5411
+ 400, {"error": "body required (<=4 KB)", "field": "$"}
5412
+ )
5256
5413
  return
5257
5414
  try:
5258
5415
  payload = json.loads(self.rfile.read(length).decode("utf-8"))
5259
5416
  except (UnicodeDecodeError, json.JSONDecodeError):
5260
- self._respond_json(400, {"error": "malformed json"})
5417
+ self._respond_json(400, {"error": "malformed json", "field": "$"})
5261
5418
  return
5262
5419
  if not isinstance(payload, dict):
5263
- self._respond_json(400, {"error": "expected JSON object"})
5420
+ self._respond_json(
5421
+ 400, {"error": "expected JSON object", "field": "$"}
5422
+ )
5264
5423
  return
5265
5424
 
5266
5425
  # Reject unknown top-level keys (forward-compat hygiene).
5267
- allowed_top_keys = {
5268
- "display", "alerts", "update", "cache_report", "budget", "dashboard",
5269
- }
5270
5426
  for k in payload.keys():
5271
- if k not in allowed_top_keys:
5427
+ if k not in SETTINGS_TOP_LEVEL_BLOCKS:
5272
5428
  self._respond_json(
5273
- 400, {"error": f"unknown settings key: {k}"}
5429
+ 400, {"error": f"unknown settings key: {k}", "field": k}
5274
5430
  )
5275
5431
  return
5276
5432
 
5277
- # Body must touch at least one known block.
5278
- if (
5279
- "display" not in payload
5280
- and "alerts" not in payload
5281
- and "update" not in payload
5282
- and "cache_report" not in payload
5283
- and "budget" not in payload
5284
- and "dashboard" not in payload
5285
- ):
5433
+ # Body must name at least one known block.
5434
+ if not (set(payload) & SETTINGS_TOP_LEVEL_BLOCKS):
5286
5435
  self._respond_json(
5287
5436
  400,
5288
5437
  {"error": (
5289
5438
  "body must contain at least one of: "
5290
- "display, alerts, update, cache_report, budget, dashboard"
5291
- )},
5439
+ + ", ".join(_SETTINGS_BLOCK_ORDER)
5440
+ ), "field": "$"},
5441
+ )
5442
+ return
5443
+
5444
+ # Classify every submitted path against the one contract. This runs
5445
+ # BEFORE the per-block validators and before the budget.codex
5446
+ # fail-closed guard, so {"budget":{"codex":{"bogus":1}}} reports the
5447
+ # unknown leaf rather than the prerequisite.
5448
+ settings_rejection, ignored_fields = _classify_settings_payload(payload)
5449
+ if settings_rejection is not None:
5450
+ message, field_path = settings_rejection
5451
+ self._respond_json(400, {"error": message, "field": field_path})
5452
+ return
5453
+
5454
+ # A named block with no leaves is a no-op; `display` is the one block
5455
+ # that declares a required leaf.
5456
+ missing_leaf = _settings_missing_required_leaf(payload)
5457
+ if missing_leaf is not None:
5458
+ self._respond_json(
5459
+ 400, {"error": f"missing {missing_leaf}", "field": missing_leaf}
5292
5460
  )
5293
5461
  return
5294
5462
 
@@ -5318,9 +5486,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5318
5486
  cache_report_block
5319
5487
  )
5320
5488
  except _CacheReportConfigError as exc:
5489
+ # The validator names the bare leaf (its pure-function tests
5490
+ # pin that); the HTTP contract is dotted end to end, so
5491
+ # qualify it here rather than changing the kernel.
5492
+ field_path = exc.field or "cache_report"
5493
+ if field_path != "cache_report":
5494
+ field_path = f"cache_report.{field_path}"
5321
5495
  self._respond_json(
5322
- 400,
5323
- {"error": str(exc), "field": exc.field or "cache_report"},
5496
+ 400, {"error": str(exc), "field": field_path},
5324
5497
  )
5325
5498
  return
5326
5499
 
@@ -5329,11 +5502,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5329
5502
  display_canonical: "str | None" = None
5330
5503
  if "display" in payload:
5331
5504
  display_block = payload["display"]
5332
- if not isinstance(display_block, dict) or "tz" not in display_block:
5333
- self._respond_json(
5334
- 400, {"error": "missing display.tz", "field": "display.tz"}
5335
- )
5336
- return
5337
5505
  try:
5338
5506
  display_canonical = normalize_display_tz_value(display_block["tz"])
5339
5507
  except ValueError:
@@ -5363,7 +5531,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5363
5531
  alerts_block = payload["alerts"]
5364
5532
  if not isinstance(alerts_block, dict):
5365
5533
  self._respond_json(
5366
- 400, {"error": "alerts must be an object"}
5534
+ 400, {"error": "alerts must be an object", "field": "alerts"}
5367
5535
  )
5368
5536
  return
5369
5537
  if "enabled" in alerts_block and not isinstance(
@@ -5371,7 +5539,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5371
5539
  ):
5372
5540
  self._respond_json(
5373
5541
  400,
5374
- {"error": "alerts.enabled must be a JSON boolean"},
5542
+ {"error": "alerts.enabled must be a JSON boolean",
5543
+ "field": "alerts.enabled"},
5375
5544
  )
5376
5545
  return
5377
5546
  if "projected_enabled" in alerts_block and not isinstance(
@@ -5379,20 +5548,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5379
5548
  ):
5380
5549
  self._respond_json(
5381
5550
  400,
5382
- {"error": "alerts.projected_enabled must be a JSON boolean"},
5383
- )
5384
- return
5385
- # The dispatch command template is CLI/config-only — never
5386
- # settable via the dashboard (it routinely holds secrets and the
5387
- # dashboard echoes settings to the client). Reject it explicitly
5388
- # rather than silently dropping it.
5389
- if "command_template" in alerts_block:
5390
- self._respond_json(
5391
- 400,
5392
- {"error": "alerts.command_template is CLI/config-only "
5393
- "(not settable via the dashboard)"},
5551
+ {"error": "alerts.projected_enabled must be a JSON boolean",
5552
+ "field": "alerts.projected_enabled"},
5394
5553
  )
5395
5554
  return
5555
+ # `alerts.command_template` is refused by the classification pass
5556
+ # above (it routinely holds secrets and the dashboard echoes
5557
+ # settings back to the client); its purpose-written message lives
5558
+ # in _SETTINGS_PURPOSE_WRITTEN_REJECTIONS.
5559
+ #
5396
5560
  # `notifier` is settable (the backend selector). Structural type
5397
5561
  # check only; the enum + cross-field rule (command needs a stored
5398
5562
  # template) is enforced free by `_get_alerts_config(merged)` below.
@@ -5400,7 +5564,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5400
5564
  alerts_block["notifier"], str
5401
5565
  ):
5402
5566
  self._respond_json(
5403
- 400, {"error": "alerts.notifier must be a string"}
5567
+ 400, {"error": "alerts.notifier must be a string",
5568
+ "field": "alerts.notifier"}
5404
5569
  )
5405
5570
  return
5406
5571
 
@@ -5412,17 +5577,18 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5412
5577
  budget_block = payload["budget"]
5413
5578
  if not isinstance(budget_block, dict):
5414
5579
  self._respond_json(
5415
- 400, {"error": "budget must be an object"}
5580
+ 400, {"error": "budget must be an object", "field": "budget"}
5416
5581
  )
5417
5582
  return
5418
5583
 
5419
- # Pre-validate the dashboard block (spec §5). Only
5420
- # ``cache_failure_markers`` is dashboard-writable a JSON boolean
5421
- # (string/int rejected, mirroring the strict bool checks for
5422
- # ``alerts.enabled``). ``dashboard.bind`` / ``dashboard.expose_transcripts``
5423
- # are bind-time / privacy-gate settings, NOT live-mutable, so they are
5424
- # rejected explicitly here (rather than silently dropped). Outside the
5425
- # config_writer_lock so a 400 short-circuit doesn't take the lock.
5584
+ # Pre-validate the dashboard block (spec §5). The writable leaves are
5585
+ # JSON booleans (string/int rejected, mirroring the strict bool check
5586
+ # for ``alerts.enabled``). ``dashboard.bind`` /
5587
+ # ``dashboard.expose_transcripts`` are bind-time / privacy-gate
5588
+ # settings, NOT live-mutable; the classification pass above refuses
5589
+ # them by name with their purpose-written message, and refuses every
5590
+ # other unknown leaf under this block. Outside the config_writer_lock
5591
+ # so a 400 short-circuit doesn't take the lock.
5426
5592
  dashboard_validated: "dict | None" = None
5427
5593
  if "dashboard" in payload:
5428
5594
  dashboard_block = payload["dashboard"]
@@ -5432,24 +5598,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5432
5598
  {"error": "dashboard must be an object", "field": "dashboard"},
5433
5599
  )
5434
5600
  return
5435
- for leaf in dashboard_block.keys():
5436
- if leaf in ("bind", "expose_transcripts"):
5437
- self._respond_json(
5438
- 400,
5439
- {"error": (f"dashboard.{leaf} is not settable via the "
5440
- "dashboard (bind-time / privacy-gate setting)"),
5441
- "field": f"dashboard.{leaf}"},
5442
- )
5443
- return
5444
- if leaf not in (
5445
- "cache_failure_markers", "live_tail", "lan_auth"
5446
- ):
5447
- self._respond_json(
5448
- 400,
5449
- {"error": f"unknown dashboard settings key: {leaf}",
5450
- "field": f"dashboard.{leaf}"},
5451
- )
5452
- return
5453
5601
  dashboard_validated = {}
5454
5602
  for _leaf in ("cache_failure_markers", "live_tail", "lan_auth"):
5455
5603
  if _leaf in dashboard_block:
@@ -5463,27 +5611,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5463
5611
  dashboard_validated[_leaf] = dashboard_block[_leaf]
5464
5612
 
5465
5613
  # Pre-validate update shape. Only `update.check.{enabled,ttl_hours}`
5466
- # is settable today; any other key under `update` or `update.check`
5467
- # is rejected so adding e.g. `update.banner.*` later is forward
5468
- # compatible. `enabled` must be a JSON bool; `ttl_hours` an int
5469
- # (bools rejected — see _validate_update_check_ttl_hours_value).
5614
+ # and `update.channel` are settable today; the classification pass
5615
+ # above refuses any other key under `update` or `update.check`, so
5616
+ # adding e.g. `update.banner.*` later is forward compatible.
5617
+ # `enabled` must be a JSON bool; `ttl_hours` an int (bools rejected —
5618
+ # see _validate_update_check_ttl_hours_value).
5470
5619
  update_check_validated: "dict | None" = None
5471
5620
  update_channel_validated: "str | None" = None
5472
5621
  if "update" in payload:
5473
5622
  update_in = payload["update"]
5474
5623
  if not isinstance(update_in, dict):
5475
5624
  self._respond_json(
5476
- 400, {"error": "update must be an object"}
5625
+ 400, {"error": "update must be an object", "field": "update"}
5477
5626
  )
5478
5627
  return
5479
- for inner in update_in.keys():
5480
- if inner not in ("check", "channel"):
5481
- self._respond_json(
5482
- 400,
5483
- {"error": f"unknown update settings key: {inner}",
5484
- "field": f"update.{inner}"},
5485
- )
5486
- return
5487
5628
  # Release channel opt-in (beta-channel, spec 2026-07-21 §3). Enum
5488
5629
  # {stable,beta}; 400 on invalid. Mirrors the config-key validator.
5489
5630
  if "channel" in update_in:
@@ -5507,14 +5648,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5507
5648
  "field": "update.check"},
5508
5649
  )
5509
5650
  return
5510
- for leaf in check_in.keys():
5511
- if leaf not in ("enabled", "ttl_hours"):
5512
- self._respond_json(
5513
- 400,
5514
- {"error": f"unknown update.check key: {leaf}",
5515
- "field": f"update.check.{leaf}"},
5516
- )
5517
- return
5518
5651
  update_check_validated = {}
5519
5652
  if "enabled" in check_in:
5520
5653
  if not isinstance(check_in["enabled"], bool):
@@ -5569,7 +5702,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5569
5702
  existing_alerts, dict
5570
5703
  ):
5571
5704
  self._respond_json(
5572
- 400, {"error": "alerts must be an object"}
5705
+ 400, {"error": "alerts must be an object",
5706
+ "field": "alerts"}
5573
5707
  )
5574
5708
  return
5575
5709
  merged_alerts = dict(existing_alerts or {})
@@ -5589,7 +5723,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5589
5723
  try:
5590
5724
  _get_alerts_config(merged)
5591
5725
  except _AlertsConfigError as exc:
5592
- self._respond_json(400, {"error": str(exc)})
5726
+ self._respond_json(
5727
+ 400,
5728
+ {"error": str(exc), "field": exc.field or "alerts"},
5729
+ )
5593
5730
  return
5594
5731
 
5595
5732
  if "budget" in payload:
@@ -5603,7 +5740,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5603
5740
  existing_budget, dict
5604
5741
  ):
5605
5742
  self._respond_json(
5606
- 400, {"error": "budget must be an object"}
5743
+ 400, {"error": "budget must be an object",
5744
+ "field": "budget"}
5607
5745
  )
5608
5746
  return
5609
5747
  merged_budget = dict(existing_budget or {})
@@ -5625,7 +5763,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5625
5763
  incoming_codex = budget_in["codex"]
5626
5764
  if not isinstance(incoming_codex, dict):
5627
5765
  self._respond_json(
5628
- 400, {"error": "budget.codex must be an object"}
5766
+ 400, {"error": "budget.codex must be an object",
5767
+ "field": "budget.codex"}
5629
5768
  )
5630
5769
  return
5631
5770
  existing_codex = merged_budget.get("codex")
@@ -5633,16 +5772,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5633
5772
  # Fail closed: the dashboard only TOGGLES an existing
5634
5773
  # Codex budget — amounts are CLI-only — so it must never
5635
5774
  # invent one. The frontend disables the toggle, this
5636
- # backstops a direct POST.
5775
+ # backstops a direct POST. Reached only when every
5776
+ # submitted leaf is known: classification runs first.
5637
5777
  self._respond_json(400, {"error": (
5638
5778
  "no Codex budget configured — set one via the CLI "
5639
5779
  "first (cctally budget set <amount> --vendor codex)"
5640
- )})
5780
+ ), "field": "budget.codex"})
5641
5781
  return
5642
5782
  merged_codex = dict(existing_codex)
5643
5783
  for sub in ("alerts_enabled", "projected_enabled"):
5644
5784
  if sub in incoming_codex:
5645
- merged_codex[sub] = bool(incoming_codex[sub])
5785
+ # Assigned verbatim. A bool() here would coerce
5786
+ # "yes" to True and destroy the evidence before
5787
+ # _get_budget_config could reject it, which is
5788
+ # exactly how this leaf came to disagree with its
5789
+ # Claude sibling (#513 F5). One boolean rule, and
5790
+ # it lives in the canonical validator.
5791
+ merged_codex[sub] = incoming_codex[sub]
5646
5792
  merged_budget["codex"] = merged_codex
5647
5793
  merged["budget"] = merged_budget
5648
5794
  # Final validation against the merged block.
@@ -5651,7 +5797,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5651
5797
  try:
5652
5798
  _get_budget_config(merged)
5653
5799
  except _BudgetConfigError as exc:
5654
- self._respond_json(400, {"error": str(exc)})
5800
+ self._respond_json(
5801
+ 400,
5802
+ {"error": str(exc), "field": exc.field or "budget"},
5803
+ )
5655
5804
  return
5656
5805
 
5657
5806
  if (
@@ -5860,6 +6009,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5860
6009
  }
5861
6010
  if "lan_auth" in dashboard_validated:
5862
6011
  out["restart_required"] = ["dashboard.lan_auth"]
6012
+ if ignored_fields:
6013
+ # Disclosure, not a new capability: these leaves were always
6014
+ # accepted-and-not-persisted, and a client had no way to tell
6015
+ # that from a plain 200. Additive and emitted only when
6016
+ # non-empty, so an ordinary write keeps its exact current shape.
6017
+ # snake_case matches the surrounding legacy echo
6018
+ # (`command_configured`, `saved_at`); CRUD echoes carry no
6019
+ # schemaVersion (docs/cli-contract.md).
6020
+ out["ignored_fields"] = ignored_fields
5863
6021
  out["saved_at"] = (
5864
6022
  dt.datetime.now(dt.timezone.utc)
5865
6023
  .strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -7212,7 +7370,7 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
7212
7370
  tui.SyncFailureAttribution(
7213
7371
  leg="stats-open",
7214
7372
  database="stats",
7215
- corruption=isinstance(exc, c.StatsHealDeferred),
7373
+ corruption=tui._stats_open_failure_is_corruption(exc),
7216
7374
  ),
7217
7375
  ),
7218
7376
  "doctor_payload": doctor_payload,
@@ -26,6 +26,10 @@ import datetime as dt
26
26
  import sys
27
27
  from dataclasses import dataclass
28
28
 
29
+ from _lib_dashboard_settings_contract import (
30
+ SETTINGS_LEAF_DISPOSITIONS as _SETTINGS_LEAF_DISPOSITIONS,
31
+ WRITABLE as _WRITABLE,
32
+ )
29
33
  from _lib_fmt import stable_sum
30
34
  from _lib_pricing import _calculate_entry_cost
31
35
  from _lib_pricing import claude_usage_dict as _claude_usage_dict
@@ -58,7 +62,19 @@ class _CacheReportConfigError(Exception):
58
62
  self.field = field
59
63
 
60
64
 
61
- _CACHE_REPORT_ALLOWED_KEYS = frozenset({"anomaly_threshold_pp"})
65
+ #: Derived from the one settings contract rather than restated here, so this
66
+ #: validator and ``POST /api/settings``'s classification pass cannot disagree
67
+ #: about what the ``cache_report`` block may carry (#513 S1).
68
+ #:
69
+ #: The disposition is part of the filter, not just the prefix: a future
70
+ #: ``cache_report.*`` leaf marked ``KNOWN_IGNORED`` is by definition one the
71
+ #: endpoint does not write, and admitting it here would let this validator
72
+ #: treat it as settable.
73
+ _CACHE_REPORT_ALLOWED_KEYS = frozenset(
74
+ path.split(".", 1)[1]
75
+ for path, disposition in _SETTINGS_LEAF_DISPOSITIONS.items()
76
+ if path.startswith("cache_report.") and disposition == _WRITABLE
77
+ )
62
78
 
63
79
 
64
80
  def _validate_cache_report_settings(block: dict) -> dict:
@@ -81,6 +97,13 @@ def _validate_cache_report_settings(block: dict) -> dict:
81
97
  raise _CacheReportConfigError(
82
98
  "cache_report must be an object", field="cache_report",
83
99
  )
100
+ # Defense in depth, and no longer the message an HTTP client sees. Since
101
+ # #513 S1 the endpoint classifies every submitted path first, so a POST of
102
+ # `{"cache_report": {"foo": 1}}` is answered by the classification pass
103
+ # with `unknown settings key: cache_report.foo` and
104
+ # `field: "cache_report.foo"` before this validator runs. This branch is
105
+ # retained for callers that reach the validator directly, which is how
106
+ # `bin/cctally`'s re-export surface exposes it.
84
107
  for key in block:
85
108
  if key not in _CACHE_REPORT_ALLOWED_KEYS:
86
109
  raise _CacheReportConfigError(
@@ -1404,6 +1404,17 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1404
1404
  "five_hour_thresholds": list(_alerts_cfg["five_hour_thresholds"]),
1405
1405
  "budget_thresholds": list(_budget_cfg["alert_thresholds"]),
1406
1406
  "budget_enabled": _budget_alerts_active(_budget_cfg),
1407
+ # Claude weekly budget AMOUNT mirror (#513 S2 §5.1). `budget_enabled`
1408
+ # above collapses "no budget set" and "budget set, alerts off" into one
1409
+ # false, so Settings could not state which of the two a user is in, nor
1410
+ # name the remedy. The amount itself distinguishes them. It is read
1411
+ # from the same validated `_budget_cfg` the neighbouring keys use, and
1412
+ # the `_BudgetConfigError` fallback dict already carries
1413
+ # `"weekly_usd": None`, so the null path needs no separate default.
1414
+ # Reading it back through an empty `POST {"budget": {}}` is NOT an
1415
+ # option: that request reaches `save_config` and triggers the
1416
+ # synchronous rebuild, so this mirror is the only read path.
1417
+ "weekly_usd": _budget_cfg.get("weekly_usd"),
1407
1418
  # Projected-pace opt-in mirrors (#121). Two flags, one per parent
1408
1419
  # axis — the frontend SettingsOverlay seeds two toggles. Sourced
1409
1420
  # from the validated getters' ``projected_enabled`` (default False).