cctally 1.20.1 → 1.20.2

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,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.20.2] - 2026-05-28
9
+
10
+ ### Fixed
11
+ - **`cctally record-usage` now rejects implausibly-dated `--resets-at` epochs before they can poison the SQLite stats history, and refuses to charge already-expired 5h windows against the previous block.** A real 366-day-off statusline payload silently wrote a phantom-year row that displaced subsequent weeks in `cctally report` / `cctally-dollar-per-percent`. The new guard validates both arguments before any `datetime.fromtimestamp()` call (so ms-epochs, year-off bugs, and negative epochs reject gracefully instead of crashing): `--resets-at ∈ [now − 30d, now + 8d]` exits 2 on miss (preserving the documented "manually replay a missed snapshot" path within 30d), and `--five-hour-resets-at ∈ [now − 10m, now + 6h]` drops the 5h fields and continues with the weekly snapshot on miss (so a stale-5h replay still writes its weekly row). The 10-minute past slack matches `_FIVE_HOUR_JITTER_FLOOR_SECONDS` — wide enough for boundary jitter / clock skew, tight enough that lagged status-line responses around a 5h rollover can no longer pollute the prior block's `_compute_block_totals` with entries that belong to the new window. The refresh-layer wrapper (`_refresh_usage_inproc`) surfaces guard misses as `status="record_failed"` so the dashboard / CLI don't silently report success on a dropped payload. (#112)
12
+
8
13
  ## [1.20.1] - 2026-05-28
9
14
 
10
15
  ### Fixed
@@ -171,6 +171,7 @@ from _cctally_core import (
171
171
  make_week_ref,
172
172
  _get_alerts_config,
173
173
  _AlertsConfigError,
174
+ _command_as_of,
174
175
  )
175
176
  from _lib_five_hour import _canonical_5h_window_key
176
177
  from _lib_pricing import _calculate_entry_cost
@@ -300,6 +301,35 @@ def _hook_tick_make_mock_refresh(*args, **kwargs):
300
301
  # and no dependency on cctally's module instance.
301
302
  _PERCENT_NORMALIZE_DECIMALS = 10
302
303
 
304
+ # Plausibility band for --resets-at / --five-hour-resets-at (issue #112).
305
+ # Out-of-band epochs are guarded at cmd_record_usage ingress before any
306
+ # datetime.fromtimestamp() call, so absurd values (ms-epochs, year-off
307
+ # bugs) can't crash the call or stamp phantom-week rows.
308
+ #
309
+ # The two bands are deliberately asymmetric and reject differently:
310
+ #
311
+ # --resets-at: 30d past / 8d future. Wide past slack preserves the
312
+ # documented "manually replay a missed snapshot" use case
313
+ # (docs/commands/record-usage.md). Out-of-band → return 2
314
+ # (entire call rejected, no weekly row written).
315
+ #
316
+ # --five-hour-resets-at: 10m past / 6h future. Tight past slack is
317
+ # intentional: maybe_update_five_hour_block computes
318
+ # _compute_block_totals(block_start_at, captured_at_dt) where
319
+ # captured_at_dt ≈ now and block_start_at = resets_at - 5h, so
320
+ # accepting an already-expired 5h resets_at pollutes the prior
321
+ # block with session_entries that belong to the NEXT block. 10m
322
+ # matches _FIVE_HOUR_JITTER_FLOOR_SECONDS (the canonical-window-key
323
+ # jitter floor) — enough for boundary jitter / clock skew, not
324
+ # enough for cross-block pollution. Out-of-band → drop the 5h
325
+ # fields and continue (the weekly snapshot still writes), so a
326
+ # manual replay with stale 5h flags doesn't fail-close on a
327
+ # documented recovery path.
328
+ _RECORD_USAGE_WEEK_PAST_SLACK_S = 30 * 86400
329
+ _RECORD_USAGE_WEEK_FUTURE_BAND_S = 8 * 86400
330
+ _RECORD_USAGE_5H_PAST_SLACK_S = 600 # 10 min; matches _FIVE_HOUR_JITTER_FLOOR_SECONDS
331
+ _RECORD_USAGE_5H_FUTURE_BAND_S = 6 * 3600
332
+
303
333
 
304
334
  # One-shot guard so a misbehaving caller passing a non-int
305
335
  # fiveHourWindowKey doesn't spam the log on every insert. Set on first
@@ -1273,6 +1303,24 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1273
1303
  weekly_percent = _normalize_percent(args.percent)
1274
1304
  resets_at = int(args.resets_at)
1275
1305
 
1306
+ # Plausibility guard (issue #112). Band-check epochs BEFORE any
1307
+ # dt.datetime.fromtimestamp() call so absurd values (ms-epoch,
1308
+ # year-off bugs, negative) reject gracefully instead of raising
1309
+ # OverflowError. Reject path returns exit 2 so
1310
+ # _refresh_usage_inproc maps it to status="record_failed" instead
1311
+ # of silently reporting success on a dropped payload.
1312
+ now_dt = _command_as_of()
1313
+ now_epoch = int(now_dt.timestamp())
1314
+ if not (now_epoch - _RECORD_USAGE_WEEK_PAST_SLACK_S
1315
+ <= resets_at
1316
+ <= now_epoch + _RECORD_USAGE_WEEK_FUTURE_BAND_S):
1317
+ eprint(
1318
+ f"[record-usage] rejecting --resets-at={resets_at}: outside "
1319
+ f"plausibility band [now-30d, now+8d]; "
1320
+ f"now={now_epoch} ({now_dt.isoformat()}). No row written."
1321
+ )
1322
+ return 2
1323
+
1276
1324
  five_hour_percent: float | None = None
1277
1325
  five_hour_resets_at_str: str | None = None
1278
1326
  five_hour_window_key: int | None = None
@@ -1281,9 +1329,35 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1281
1329
  five_hour_percent = _normalize_percent(args.five_hour_percent)
1282
1330
  if args.five_hour_resets_at is not None:
1283
1331
  five_hour_resets_at_epoch = int(args.five_hour_resets_at)
1284
- five_hour_resets_at_str = dt.datetime.fromtimestamp(
1285
- five_hour_resets_at_epoch, tz=dt.timezone.utc
1286
- ).isoformat(timespec="seconds")
1332
+ # Band-check BEFORE fromtimestamp (issue #112).
1333
+ #
1334
+ # Out-of-band 5h is non-fatal: drop the 5h fields and continue
1335
+ # so the weekly snapshot still writes. Two motivating cases:
1336
+ # (a) docs' manual-replay path (record-usage.md) emits the
1337
+ # original status-line args verbatim, including stale 5h
1338
+ # flags — rejecting the whole call there contradicts the
1339
+ # wider 30d weekly past slack.
1340
+ # (b) An already-expired 5h resets_at would pollute the prior
1341
+ # block's totals (block_start_at = resets_at - 5h →
1342
+ # _compute_block_totals charges entries past the real
1343
+ # reset to this block). Dropping the 5h portion here
1344
+ # skips maybe_update_five_hour_block entirely.
1345
+ if not (now_epoch - _RECORD_USAGE_5H_PAST_SLACK_S
1346
+ <= five_hour_resets_at_epoch
1347
+ <= now_epoch + _RECORD_USAGE_5H_FUTURE_BAND_S):
1348
+ eprint(
1349
+ f"[record-usage] dropping --five-hour-resets-at="
1350
+ f"{five_hour_resets_at_epoch}: outside plausibility band "
1351
+ f"[now-10m, now+6h]; now={now_epoch} "
1352
+ f"({now_dt.isoformat()}). Weekly snapshot still written; "
1353
+ f"5h fields will be NULL."
1354
+ )
1355
+ five_hour_percent = None
1356
+ five_hour_resets_at_epoch = None
1357
+ else:
1358
+ five_hour_resets_at_str = dt.datetime.fromtimestamp(
1359
+ five_hour_resets_at_epoch, tz=dt.timezone.utc
1360
+ ).isoformat(timespec="seconds")
1287
1361
  # five_hour_window_key derivation is deferred until after open_db()
1288
1362
  # so we can pass the most-recent stored sample as the prior anchor.
1289
1363
  # See _canonical_5h_window_key docstring (spec invariant #3:
@@ -1406,7 +1480,12 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1406
1480
  prior["week_end_at"], "record.prior"
1407
1481
  )
1408
1482
  prior_pct = prior["weekly_percent"]
1409
- now_utc = dt.datetime.now(dt.timezone.utc)
1483
+ # Use _command_as_of() so CCTALLY_AS_OF pins the predicate
1484
+ # for tests (no behavior change in production — falls back
1485
+ # to wall-clock when the env hook is unset). This makes
1486
+ # mid-week-reset detection deterministic against fixtures
1487
+ # whose `prior_end` is a fixed historical instant.
1488
+ now_utc = _command_as_of()
1410
1489
  if prior_end_canon and prior_end_canon != cur_end_canon:
1411
1490
  prior_end_dt = parse_iso_datetime(prior_end_canon, "prior.week_end_at")
1412
1491
  # Fire only when (a) prior window was still in the FUTURE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.20.1",
3
+ "version": "1.20.2",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {