cctally 1.80.3 → 1.81.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +459 -59
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +55 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_quota.py +81 -4
  41. package/bin/_lib_render.py +19 -5
  42. package/bin/_lib_share.py +25 -0
  43. package/bin/_lib_snapshot_cache.py +8 -0
  44. package/bin/_lib_subscription_weeks.py +16 -2
  45. package/bin/_lib_view_models.py +18 -9
  46. package/bin/cctally +43 -4
  47. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  48. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  49. package/dashboard/static/dashboard.html +2 -2
  50. package/package.json +4 -1
  51. package/dashboard/static/assets/index-B3y14l1o.js +0 -92
  52. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -201,6 +201,7 @@ from _cctally_core import (
201
201
  _command_as_of,
202
202
  _as_of_or_command,
203
203
  )
204
+ import _lib_accounts # pure stdlib kernel; UNATTRIBUTED sentinel default (#341)
204
205
  from _lib_five_hour import _canonical_5h_window_key, five_hour_milestone_range
205
206
  from _lib_pricing import _calculate_entry_cost
206
207
  from _lib_codex_hooks import (
@@ -446,6 +447,26 @@ def _warn_budget_bad_config_once(*args, **kwargs):
446
447
  return sys.modules["cctally"]._warn_budget_bad_config_once(*args, **kwargs)
447
448
 
448
449
 
450
+ _BUDGET_ANCHOR_WARNED = False
451
+
452
+
453
+ def _warn_budget_active_anchor_unavailable_once():
454
+ """One-shot stderr WARN (#341 Step 4-eval, spec §6 `*`-anchor): the Claude
455
+ vendor-wide subscription-week budget ladder was SKIPPED this tick because the
456
+ active-account anchor is genuinely unavailable (a torn `~/.claude.json`
457
+ read). Throttled to once per process so a persistent torn read never spams
458
+ the hot path; the persistent health signal is the doctor `accounts.identity`
459
+ WARN leg."""
460
+ global _BUDGET_ANCHOR_WARNED
461
+ if _BUDGET_ANCHOR_WARNED:
462
+ return
463
+ _BUDGET_ANCHOR_WARNED = True
464
+ eprint(
465
+ "warning: skipping vendor-wide Claude budget alert — the active account "
466
+ "anchor is unavailable (torn ~/.claude.json read); see `cctally doctor`"
467
+ )
468
+
469
+
449
470
  def _hook_tick_oauth_refresh(*args, **kwargs):
450
471
  """Shim for ``_hook_tick_oauth_refresh``.
451
472
 
@@ -538,9 +559,13 @@ _logged_window_key_coerce_failure = False
538
559
  def _resolve_active_five_hour_reset_event_id(
539
560
  conn: "sqlite3.Connection",
540
561
  five_hour_window_key: int,
562
+ *,
563
+ account_key: str = _lib_accounts.UNATTRIBUTED,
541
564
  ) -> int:
542
565
  """Return ``id`` of the most-recent ``five_hour_reset_events`` row for
543
- ``five_hour_window_key``, else 0 (pre-credit / no-event sentinel).
566
+ ``(account_key, five_hour_window_key)``, else 0 (pre-credit / no-event
567
+ sentinel). ``account_key`` (#341) scopes the active-segment resolution to
568
+ the account so a shared physical window keeps per-account segments.
544
569
 
545
570
  Mirrors the weekly active-segment resolution pattern used by
546
571
  ``maybe_record_milestone`` for ``percent_milestones.reset_event_id``.
@@ -565,9 +590,9 @@ def _resolve_active_five_hour_reset_event_id(
565
590
  try:
566
591
  row = conn.execute(
567
592
  "SELECT id FROM five_hour_reset_events "
568
- "WHERE five_hour_window_key = ? "
593
+ "WHERE five_hour_window_key = ? AND account_key = ? "
569
594
  "ORDER BY id DESC LIMIT 1",
570
- (int(five_hour_window_key),),
595
+ (int(five_hour_window_key), account_key),
571
596
  ).fetchone()
572
597
  except sqlite3.DatabaseError:
573
598
  return 0
@@ -583,10 +608,17 @@ def maybe_record_milestone(
583
608
  as_of: "str | None" = None,
584
609
  alert_sink: "list | None" = None,
585
610
  journal: "tuple | None" = None,
611
+ account_key: str = _lib_accounts.UNATTRIBUTED,
586
612
  ) -> None:
587
613
  """Check if a new integer percent threshold was crossed, and if so,
588
614
  fetch cost and record the milestone. Errors are logged, not raised.
589
615
 
616
+ ``account_key`` (#341): the account the crossing belongs to. Scopes the
617
+ per-account milestone ledger (max/marginal reads, INSERT, alerted_at
618
+ UPDATE) so two accounts crossing the SAME threshold in the SAME week each
619
+ record independently. Default ``"unattributed"`` is the rev-4.1 defensive
620
+ backstop; the ingest pipeline hook passes the resolved account explicitly.
621
+
590
622
  Transaction-neutral / capture-time-pure seam (DB journal redesign §5.2.3):
591
623
  when ``conn`` is passed the crossing folds into the caller's transaction —
592
624
  no internal ``open_db()``/``commit()``/``close()``, and alert dispatch is
@@ -646,16 +678,18 @@ def maybe_record_milestone(
646
678
  """
647
679
  SELECT id FROM week_reset_events
648
680
  WHERE new_week_end_at = ?
681
+ AND account_key = ?
649
682
  AND unixepoch(effective_reset_at_utc) <= unixepoch(?)
650
683
  ORDER BY id DESC LIMIT 1
651
684
  """,
652
- (week_end_at, captured_at_iso),
685
+ (week_end_at, account_key, captured_at_iso),
653
686
  ).fetchone()
654
687
  if seg_row is not None:
655
688
  reset_event_id = int(seg_row["id"])
656
689
 
657
690
  max_existing = get_max_milestone_for_week(
658
691
  conn, week_start_date, reset_event_id=reset_event_id,
692
+ account_key=account_key,
659
693
  )
660
694
  if max_existing is not None and current_floor <= max_existing:
661
695
  return
@@ -684,6 +718,9 @@ def maybe_record_milestone(
684
718
  conn=(None if own_conn else conn),
685
719
  as_of=as_of,
686
720
  journal=(None if own_conn else journal),
721
+ # Materialize the cost snapshot under the crossing's account
722
+ # (#341 P2-1) so the account-scoped read below finds it.
723
+ account_key=account_key,
687
724
  )
688
725
  except Exception as exc:
689
726
  eprint(f"[milestone] cost sync failed, using latest available: {exc}")
@@ -715,7 +752,13 @@ def maybe_record_milestone(
715
752
  cumulative_cost = live_cost
716
753
  cost_snapshot_id = 0 # no snapshot row to anchor against
717
754
  else:
718
- latest_cost = get_latest_cost_for_week(conn, week_ref)
755
+ # Account-scoped read (#341 P2-1): the cost snapshot was just
756
+ # materialized under `account_key`, so scope the read to it — the
757
+ # merged (account-blind) read would return another account's row on
758
+ # a multi-account install. Byte-stable at single-account (the sole
759
+ # row is the crossing account's).
760
+ latest_cost = get_latest_cost_for_week(
761
+ conn, week_ref, account_key=account_key)
719
762
  if latest_cost is None:
720
763
  eprint("[milestone] no cost snapshot yet for this week, skipping")
721
764
  return
@@ -754,6 +797,7 @@ def maybe_record_milestone(
754
797
  prev_cost = get_milestone_cost_for_week(
755
798
  conn, week_start_date, max_existing,
756
799
  reset_event_id=reset_event_id,
800
+ account_key=account_key,
757
801
  )
758
802
  marginal = (cumulative_cost - prev_cost) if prev_cost is not None else None
759
803
  else:
@@ -773,6 +817,7 @@ def maybe_record_milestone(
773
817
  commit=False,
774
818
  reset_event_id=reset_event_id,
775
819
  as_of=as_of,
820
+ account_key=account_key,
776
821
  )
777
822
  # ── Threshold-actions dispatch (set-then-dispatch, spec §3.2) ──
778
823
  # Only the genuine-new-crossing winner (rowcount==1) reaches this
@@ -797,9 +842,10 @@ def maybe_record_milestone(
797
842
  conn.execute(
798
843
  "UPDATE percent_milestones SET alerted_at = ? "
799
844
  "WHERE week_start_date = ? AND percent_threshold = ? "
800
- " AND reset_event_id = ? "
845
+ " AND reset_event_id = ? AND account_key = ? "
801
846
  " AND alerted_at IS NULL",
802
- (crossed_at, week_start_date, pct, reset_event_id),
847
+ (crossed_at, week_start_date, pct, reset_event_id,
848
+ account_key),
803
849
  )
804
850
  # Cheap re-read for payload context (cumulative_cost_usd
805
851
  # reflects the value persisted on insert, immune to any
@@ -811,8 +857,8 @@ def maybe_record_milestone(
811
857
  row = conn.execute(
812
858
  "SELECT cumulative_cost_usd FROM percent_milestones "
813
859
  "WHERE week_start_date = ? AND percent_threshold = ? "
814
- " AND reset_event_id = ?",
815
- (week_start_date, pct, reset_event_id),
860
+ " AND reset_event_id = ? AND account_key = ?",
861
+ (week_start_date, pct, reset_event_id, account_key),
816
862
  ).fetchone()
817
863
  if row is not None:
818
864
  cum = float(row["cumulative_cost_usd"])
@@ -824,6 +870,7 @@ def maybe_record_milestone(
824
870
  week_start_date=week_start_date,
825
871
  cumulative_cost_usd=cum,
826
872
  dollars_per_percent=dpp,
873
+ account_key=account_key,
827
874
  )
828
875
  pending_alerts.append(payload)
829
876
  # Single commit after the loop durably writes every milestone row
@@ -864,6 +911,7 @@ def maybe_record_milestone(
864
911
  def _record_budget_milestone_for_vendor(
865
912
  *, vendor, target, thresholds, period, config, tz, build_payload,
866
913
  raise_errors: bool = False, conn=None, as_of=None, alert_sink=None,
914
+ account_key: str = "*", window_account_key=None,
867
915
  ) -> int:
868
916
  """Shared budget-milestone firing core for both vendors (#143).
869
917
 
@@ -894,6 +942,7 @@ def _record_budget_milestone_for_vendor(
894
942
  ``threshold`` / ``crossed_at_utc`` / ``period_key`` / ``period`` /
895
943
  ``budget_usd`` / ``spent_usd`` / ``consumption_pct`` keyword args.
896
944
  """
945
+ import _cctally_cache # for the fail-closed AccountAttributionUnavailable skip (#341)
897
946
  now_utc = _as_of_or_command(as_of)
898
947
  pending_alerts: list[dict[str, Any]] = []
899
948
  own_conn = conn is None
@@ -902,7 +951,7 @@ def _record_budget_milestone_for_vendor(
902
951
  try:
903
952
  start_at = _resolve_budget_window(
904
953
  conn, vendor=vendor, now_utc=now_utc, period=period,
905
- config=config, tz=tz,
954
+ config=config, tz=tz, window_account_key=window_account_key,
906
955
  )
907
956
  if start_at is None:
908
957
  return 0 # no resolvable window yet (claude subscription-week pre-snapshot)
@@ -911,9 +960,9 @@ def _record_budget_milestone_for_vendor(
911
960
  present = {
912
961
  int(r[0]) for r in conn.execute(
913
962
  "SELECT threshold FROM budget_milestones "
914
- "WHERE vendor = ? AND period_start_at = ? "
963
+ "WHERE vendor = ? AND account_key = ? AND period_start_at = ? "
915
964
  " AND (period = ? OR period IS NULL)",
916
- (vendor, period_key, period),
965
+ (vendor, account_key, period_key, period),
917
966
  )
918
967
  }
919
968
  pending = [t for t in sorted(thresholds) if t not in present]
@@ -921,7 +970,8 @@ def _record_budget_milestone_for_vendor(
921
970
  return 0 # nothing left this window → skip the cost SUM
922
971
 
923
972
  spent = _budget_spend_for_vendor(
924
- conn, vendor=vendor, start_at=start_at, now_utc=now_utc
973
+ conn, vendor=vendor, start_at=start_at, now_utc=now_utc,
974
+ account_key=account_key,
925
975
  )
926
976
  # Shared INSERT-and-arm core (set-then-dispatch, fire-once via rowcount);
927
977
  # commit=False inside, so this conn owns the single durable commit below.
@@ -935,6 +985,7 @@ def _record_budget_milestone_for_vendor(
935
985
  spent=spent,
936
986
  now_utc=now_utc,
937
987
  as_of=as_of,
988
+ account_key=account_key,
938
989
  ):
939
990
  pending_alerts.append(build_payload(
940
991
  threshold=t,
@@ -944,12 +995,26 @@ def _record_budget_milestone_for_vendor(
944
995
  budget_usd=tg,
945
996
  spent_usd=sp,
946
997
  consumption_pct=pct,
998
+ account_key=account_key,
947
999
  ))
948
1000
  # Single commit: every INSERT + its alerted_at marker durable together.
949
1001
  # On the passed-conn (ingest) path the caller owns the commit + the
950
1002
  # post-commit dispatch (the ingester's ALERT_DISPATCHER).
951
1003
  if own_conn:
952
1004
  conn.commit()
1005
+ except _cctally_cache.AccountAttributionUnavailable as exc:
1006
+ # #341 (Task 4): a per-account ladder whose account-scoped spend read
1007
+ # hit the fail-closed cache guard is SKIPPED this tick — never fired on
1008
+ # wrong/merged data (the mislabel the guard prevents) and, unlike a
1009
+ # generic failure, NEVER re-raised (a transient attribution gap must not
1010
+ # abort the whole ingest cycle). Fires next healthy tick. spec §6
1011
+ # "unresolvable keys ... skipped ... never a crash". `pending_alerts`
1012
+ # is empty at this point (the spend leg precedes any crossing), so no
1013
+ # partial arm leaks. Vendor-wide (`*`) reads pass `account_key=None`
1014
+ # downstream and never trip the guard, so this only guards real ladders.
1015
+ eprint(f"[budget-milestone:{vendor}] account attribution unavailable; "
1016
+ f"skipping the {account_key} ladder this tick ({exc})")
1017
+ return 0
953
1018
  except Exception as exc:
954
1019
  # Exception discipline (6c-gate P1): passed-conn (ingest) path re-raises
955
1020
  # so a failure ABORTS the cycle (invariant ii); this covers both budget
@@ -1010,31 +1075,32 @@ def maybe_record_budget_milestone(
1010
1075
  except _BudgetConfigError as exc:
1011
1076
  _warn_budget_bad_config_once(exc)
1012
1077
  return
1013
- if not _budget_alerts_active(budget_cfg):
1014
- return
1015
- thresholds = budget_cfg["alert_thresholds"]
1016
- if not thresholds:
1078
+ thresholds = budget_cfg.get("alert_thresholds")
1079
+ if not thresholds or not budget_cfg.get("alerts_enabled"):
1017
1080
  return
1081
+ weekly_usd = budget_cfg.get("weekly_usd")
1082
+ # Per-account budget ladders (#341 Step 4-eval, spec §6): `budget.accounts` is
1083
+ # a {account_key: usd} map (refs normalised to immutable keys at write time).
1084
+ # Each real account fires its OWN ladder over its OWN stamped spend. A Claude
1085
+ # budget can be per-account-ONLY (no vendor-wide `weekly_usd`), so the gate
1086
+ # below no longer requires `weekly_usd`.
1087
+ accounts = budget_cfg.get("accounts") or {}
1088
+ if weekly_usd is None and not accounts:
1089
+ return # neither a vendor-wide nor a per-account budget → nothing to do
1018
1090
  # Period generalization (spec §6): subscription-week resolves the snapshot-
1019
1091
  # anchored window; a calendar period (calendar-week / calendar-month)
1020
1092
  # resolves the window purely from `now` + the period. config/tz are
1021
1093
  # resolved once for the calendar branch.
1022
1094
  period = budget_cfg.get("period", "subscription-week")
1023
1095
  tz = resolve_display_tz(argparse.Namespace(tz=None), config)
1024
- _record_budget_milestone_for_vendor(
1025
- vendor="claude",
1026
- target=budget_cfg["weekly_usd"],
1027
- thresholds=thresholds,
1028
- period=period,
1029
- config=config,
1030
- tz=tz,
1031
- conn=conn,
1032
- as_of=as_of,
1033
- alert_sink=alert_sink,
1096
+
1097
+ def _claude_budget_payload(**kw):
1034
1098
  # The Claude payload builder takes the legacy `week_start_at=` kwarg
1035
1099
  # (its value is the resolved period-start instant, == period_key), so
1036
1100
  # the at-fire dispatch id stays byte-stable `budget:<period_start_at>:<t>`.
1037
- build_payload=lambda **kw: _build_alert_payload_budget(
1101
+ # `account_key` (#341) rides through to the alert's [label] prefix + the
1102
+ # `alerts.log` 8th field; `*` for the vendor-wide ladder.
1103
+ return _build_alert_payload_budget(
1038
1104
  threshold=kw["threshold"],
1039
1105
  crossed_at_utc=kw["crossed_at_utc"],
1040
1106
  week_start_at=kw["period_key"],
@@ -1042,8 +1108,50 @@ def maybe_record_budget_milestone(
1042
1108
  spent_usd=kw["spent_usd"],
1043
1109
  consumption_pct=kw["consumption_pct"],
1044
1110
  period=kw["period"],
1045
- ),
1046
- )
1111
+ account_key=kw["account_key"],
1112
+ )
1113
+
1114
+ # Vendor-wide (`*`) ladder — today's semantics (sum across ALL accounts incl.
1115
+ # unattributed, the guaranteed-complete vendor total). Rev-3 `*`-anchor: a
1116
+ # subscription-week vendor budget anchors on the ACTIVE account's week; when
1117
+ # the active identity is genuinely UNAVAILABLE (a TORN `~/.claude.json` read —
1118
+ # NOT a resolved `unattributed` absence) we skip the eval + WARN rather than
1119
+ # guess a wrong anchor (the doctor `accounts.identity` leg surfaces the torn
1120
+ # read persistently). A <=1-real-account install resolves `unattributed`
1121
+ # (stably-absent) and behaves exactly as today (byte-stable).
1122
+ if weekly_usd is not None:
1123
+ skip_vendor_wide = False
1124
+ vendor_window_key = None
1125
+ if period == "subscription-week":
1126
+ ident = _cctally_core._resolve_active_claude_identity()
1127
+ if ident.get("status") == "torn":
1128
+ _warn_budget_active_anchor_unavailable_once()
1129
+ skip_vendor_wide = True
1130
+ else:
1131
+ # #341 (spec §6 rev-4): the vendor-wide (`*`) subscription-week
1132
+ # ladder anchors its PERIOD on the ACTIVE account's week (its
1133
+ # SPEND still sums every account). A <=1-real-account install
1134
+ # resolves `unattributed` → the same window as today (byte-stable).
1135
+ vendor_window_key = ident.get("account_key")
1136
+ if not skip_vendor_wide:
1137
+ _record_budget_milestone_for_vendor(
1138
+ vendor="claude", target=weekly_usd, thresholds=thresholds,
1139
+ period=period, config=config, tz=tz, conn=conn, as_of=as_of,
1140
+ alert_sink=alert_sink, account_key="*",
1141
+ window_account_key=vendor_window_key,
1142
+ build_payload=_claude_budget_payload,
1143
+ )
1144
+
1145
+ # Per-account ladders — one per real account in `budget.accounts`. Each
1146
+ # anchors its period on its OWN account's subscription week (spec §6).
1147
+ for acct_key, acct_usd in accounts.items():
1148
+ _record_budget_milestone_for_vendor(
1149
+ vendor="claude", target=acct_usd, thresholds=thresholds,
1150
+ period=period, config=config, tz=tz, conn=conn, as_of=as_of,
1151
+ alert_sink=alert_sink, account_key=acct_key,
1152
+ window_account_key=acct_key,
1153
+ build_payload=_claude_budget_payload,
1154
+ )
1047
1155
 
1048
1156
 
1049
1157
  def maybe_record_project_budget_milestone(
@@ -1093,7 +1201,20 @@ def maybe_record_project_budget_milestone(
1093
1201
  if own_conn:
1094
1202
  conn = open_db()
1095
1203
  try:
1096
- window = _resolve_current_budget_window(conn, now_utc)
1204
+ # #341 (spec §6 rev-4): project_budget_milestones is a `*`-scoped
1205
+ # subscription-week writer, so it anchors "the current week" on the
1206
+ # ACTIVE account's week under the same skip+WARN rule as the vendor
1207
+ # budget ladder. TORN identity → skip + one-shot WARN (never guess an
1208
+ # anchor); identified/stably-absent → scope the window to that account
1209
+ # (<=1-real-account resolves `unattributed` = byte-stable).
1210
+ pb_window_key = None
1211
+ ident = _cctally_core._resolve_active_claude_identity()
1212
+ if ident.get("status") == "torn":
1213
+ _warn_budget_active_anchor_unavailable_once()
1214
+ return
1215
+ pb_window_key = ident.get("account_key")
1216
+ window = _resolve_current_budget_window(
1217
+ conn, now_utc, account_key=pb_window_key)
1097
1218
  if window is None:
1098
1219
  return # no resolvable week window yet
1099
1220
  week_start_at, _week_end_at = window
@@ -1272,20 +1393,22 @@ def maybe_record_codex_budget_milestone(
1272
1393
  return 0
1273
1394
  target = codex_cfg.get("amount_usd")
1274
1395
  thresholds = codex_cfg.get("alert_thresholds") or []
1275
- if target is None or not thresholds:
1396
+ # Per-account Codex ladders (#341 Step 4-eval, spec §6): `budget.codex.accounts`
1397
+ # is a {account_key: usd} map. A Codex budget can be per-account-ONLY (no
1398
+ # vendor-wide `amount_usd`), so the gate accepts either a vendor-wide amount OR
1399
+ # a non-empty per-account map. Codex is calendar-anchored (no subscription
1400
+ # week -> no `*`-anchor rule).
1401
+ accounts = codex_cfg.get("accounts") or {}
1402
+ if not thresholds or (target is None and not accounts):
1276
1403
  return 0
1277
1404
  tz = resolve_display_tz(argparse.Namespace(tz=None), config)
1278
- return _record_budget_milestone_for_vendor(
1279
- vendor="codex",
1280
- target=target,
1281
- thresholds=thresholds,
1282
- period=codex_cfg["period"],
1283
- config=config,
1284
- tz=tz,
1405
+
1406
+ def _codex_budget_payload(**kw):
1285
1407
  # The Codex payload builder takes `period_start_at=` directly (== the
1286
1408
  # resolved period-start instant, == period_key), so the at-fire dispatch
1287
1409
  # id stays byte-stable `codex_budget:<period_start_at>:<threshold>`.
1288
- build_payload=lambda **kw: _build_alert_payload_codex_budget(
1410
+ # `account_key` (#341) rides through to the [label] prefix + log field.
1411
+ return _build_alert_payload_codex_budget(
1289
1412
  threshold=kw["threshold"],
1290
1413
  crossed_at_utc=kw["crossed_at_utc"],
1291
1414
  period_start_at=kw["period_key"],
@@ -1293,12 +1416,27 @@ def maybe_record_codex_budget_milestone(
1293
1416
  budget_usd=kw["budget_usd"],
1294
1417
  spent_usd=kw["spent_usd"],
1295
1418
  consumption_pct=kw["consumption_pct"],
1296
- ),
1297
- raise_errors=raise_errors,
1298
- conn=conn,
1299
- as_of=as_of,
1300
- alert_sink=alert_sink,
1301
- )
1419
+ account_key=kw["account_key"],
1420
+ )
1421
+
1422
+ fired = 0
1423
+ if target is not None:
1424
+ fired += _record_budget_milestone_for_vendor(
1425
+ vendor="codex", target=target, thresholds=thresholds,
1426
+ period=codex_cfg["period"], config=config, tz=tz,
1427
+ build_payload=_codex_budget_payload, account_key="*",
1428
+ raise_errors=raise_errors, conn=conn, as_of=as_of,
1429
+ alert_sink=alert_sink,
1430
+ ) or 0
1431
+ for acct_key, acct_usd in accounts.items():
1432
+ fired += _record_budget_milestone_for_vendor(
1433
+ vendor="codex", target=acct_usd, thresholds=thresholds,
1434
+ period=codex_cfg["period"], config=config, tz=tz,
1435
+ build_payload=_codex_budget_payload, account_key=acct_key,
1436
+ raise_errors=raise_errors, conn=conn, as_of=as_of,
1437
+ alert_sink=alert_sink,
1438
+ ) or 0
1439
+ return fired
1302
1440
 
1303
1441
 
1304
1442
  def _weekly_pct_week_avg_projection(conn, now_utc):
@@ -1510,13 +1648,29 @@ def maybe_record_projected_alert(
1510
1648
  sorted(set(int(t) for t in budget_cfg["alert_thresholds"]))
1511
1649
  )
1512
1650
  claude_period = budget_cfg.get("period", "subscription-week")
1651
+ # #341 (spec §6 rev-4): the vendor-budget projected metric is a
1652
+ # `*`-scoped subscription-week writer, so it anchors on the ACTIVE
1653
+ # account's week under the same skip+WARN rule. TORN identity → skip
1654
+ # ONLY this leg (the weekly_pct + codex legs still run); identified /
1655
+ # stably-absent → scope the window (byte-stable at <=1 real account).
1656
+ # Calendar periods ignore the key (pure calendar).
1657
+ bu_window_key = None
1658
+ bu_leg_ok = True
1659
+ if claude_period == "subscription-week":
1660
+ _bu_ident = _cctally_core._resolve_active_claude_identity()
1661
+ if _bu_ident.get("status") == "torn":
1662
+ _warn_budget_active_anchor_unavailable_once()
1663
+ bu_leg_ok = False
1664
+ else:
1665
+ bu_window_key = _bu_ident.get("account_key")
1513
1666
  # Resolve the window key CHEAPLY first (SUM-free, same resolver the
1514
1667
  # actual-budget axis uses) so the pre-probe can short-circuit BEFORE
1515
1668
  # _build_vendor_budget_inputs runs any cost SUM / cache sync — the
1516
1669
  # pre-probe-runs-first contract (spec §3.4; mirrors the actual axis).
1517
1670
  window = _resolve_claude_budget_window(
1518
- conn, now_utc, period=claude_period, config=cfg, tz=config_tz
1519
- )
1671
+ conn, now_utc, period=claude_period, config=cfg, tz=config_tz,
1672
+ window_account_key=bu_window_key,
1673
+ ) if bu_leg_ok else None
1520
1674
  if window is not None and thresholds:
1521
1675
  b_ws_at, _b_we_at = window
1522
1676
  b_week_key = b_ws_at.isoformat(timespec="seconds")
@@ -1530,6 +1684,7 @@ def maybe_record_projected_alert(
1530
1684
  vendor="claude", period=claude_period, target_usd=target,
1531
1685
  alert_thresholds=thresholds, now_utc=now_utc, config=cfg,
1532
1686
  tz=config_tz, skip_sync=True,
1687
+ window_account_key=bu_window_key,
1533
1688
  )
1534
1689
  if inputs is not None:
1535
1690
  status = compute_budget_status(inputs)
@@ -1758,12 +1913,19 @@ def _compute_block_totals(
1758
1913
 
1759
1914
  def maybe_update_five_hour_block(
1760
1915
  saved: dict[str, Any], *, conn=None, as_of: "str | None" = None,
1761
- alert_sink: "list | None" = None,
1916
+ alert_sink: "list | None" = None, account_key: str = _lib_accounts.UNATTRIBUTED,
1762
1917
  ) -> None:
1763
1918
  """Upsert the current 5h block in five_hour_blocks; close strictly
1764
1919
  older open blocks; sweep naturally-expired blocks; flag blocks
1765
1920
  spanning a recorded mid-week 7d-reset.
1766
1921
 
1922
+ ``account_key`` (#341): the account the 5h block belongs to. Every
1923
+ block/child/milestone query below is scoped to it (composite
1924
+ ``(account_key, five_hour_window_key)`` identity — spec review finding 3) so
1925
+ two accounts observing the SAME physical 5h window each own their own block
1926
+ and children. Default ``"unattributed"`` is the rev-4.1 defensive backstop;
1927
+ the ingest pipeline hook passes the resolved account explicitly.
1928
+
1767
1929
  Errors are logged and swallowed — record-usage must not regress
1768
1930
  because of this helper, same posture as maybe_record_milestone.
1769
1931
 
@@ -1810,8 +1972,9 @@ def maybe_update_five_hour_block(
1810
1972
  block_start_at AS block_start_at
1811
1973
  FROM five_hour_blocks
1812
1974
  WHERE five_hour_window_key = ?
1975
+ AND account_key = ?
1813
1976
  """,
1814
- (int(five_hour_window_key),),
1977
+ (int(five_hour_window_key), account_key),
1815
1978
  ).fetchone()
1816
1979
 
1817
1980
  # Whole-table emptiness BEFORE this tick's upsert — the exact trigger of
@@ -1822,7 +1985,8 @@ def maybe_update_five_hour_block(
1822
1985
  # block. Gating on this (not just `resets < now`) keeps a strictly-newer
1823
1986
  # historical block open — the backfill never touched a non-first insert.
1824
1987
  blocks_were_empty = conn.execute(
1825
- "SELECT COUNT(*) FROM five_hour_blocks"
1988
+ "SELECT COUNT(*) FROM five_hour_blocks WHERE account_key = ?",
1989
+ (account_key,),
1826
1990
  ).fetchone()[0] == 0
1827
1991
 
1828
1992
  if prior is None:
@@ -1903,9 +2067,10 @@ def maybe_update_five_hour_block(
1903
2067
  UPDATE five_hour_blocks
1904
2068
  SET is_closed = 1, last_updated_at_utc = ?
1905
2069
  WHERE is_closed = 0
2070
+ AND account_key = ?
1906
2071
  AND five_hour_window_key < ?
1907
2072
  """,
1908
- (now_iso, int(five_hour_window_key)),
2073
+ (now_iso, account_key, int(five_hour_window_key)),
1909
2074
  )
1910
2075
 
1911
2076
  # Step 5b: natural-expiration sweep. The close-older predicate
@@ -1920,9 +2085,10 @@ def maybe_update_five_hour_block(
1920
2085
  UPDATE five_hour_blocks
1921
2086
  SET is_closed = 1, last_updated_at_utc = ?
1922
2087
  WHERE is_closed = 0
2088
+ AND account_key = ?
1923
2089
  AND five_hour_resets_at < ?
1924
2090
  """,
1925
- (now_iso, now_iso),
2091
+ (now_iso, account_key, now_iso),
1926
2092
  )
1927
2093
 
1928
2094
  # Step 7: atomic upsert. Single statement collapses the
@@ -1956,10 +2122,11 @@ def maybe_update_five_hour_block(
1956
2122
  total_cost_usd,
1957
2123
  is_closed,
1958
2124
  created_at_utc,
1959
- last_updated_at_utc
2125
+ last_updated_at_utc,
2126
+ account_key
1960
2127
  )
1961
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 0, ?, ?)
1962
- ON CONFLICT(five_hour_window_key) DO UPDATE SET
2128
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 0, ?, ?, ?)
2129
+ ON CONFLICT(account_key, five_hour_window_key) DO UPDATE SET
1963
2130
  last_observed_at_utc = excluded.last_observed_at_utc,
1964
2131
  final_five_hour_percent = excluded.final_five_hour_percent,
1965
2132
  seven_day_pct_at_block_end = excluded.seven_day_pct_at_block_end,
@@ -1986,16 +2153,19 @@ def maybe_update_five_hour_block(
1986
2153
  totals["cost_usd"],
1987
2154
  now_iso,
1988
2155
  now_iso,
2156
+ account_key,
1989
2157
  ),
1990
2158
  )
1991
2159
 
1992
2160
  # ── Resolve current block_id once for reuse by the per-(block, model)
1993
2161
  # / per-(block, project) child writes below AND the existing milestone
1994
2162
  # detection (which previously did its own SELECT — drop that SELECT in
1995
- # favor of this variable).
2163
+ # favor of this variable). Composite (account_key, five_hour_window_key)
2164
+ # so a shared physical window resolves THIS account's block (#341).
1996
2165
  block_id_row = conn.execute(
1997
- "SELECT id FROM five_hour_blocks WHERE five_hour_window_key = ?",
1998
- (int(five_hour_window_key),),
2166
+ "SELECT id FROM five_hour_blocks "
2167
+ "WHERE five_hour_window_key = ? AND account_key = ?",
2168
+ (int(five_hour_window_key), account_key),
1999
2169
  ).fetchone()
2000
2170
  block_id = int(block_id_row["id"])
2001
2171
 
@@ -2010,9 +2180,9 @@ def maybe_update_five_hour_block(
2010
2180
  if blocks_were_empty:
2011
2181
  conn.execute(
2012
2182
  "UPDATE five_hour_blocks SET is_closed = 1, last_updated_at_utc = ? "
2013
- "WHERE five_hour_window_key = ? AND is_closed = 0 "
2014
- " AND five_hour_resets_at < ?",
2015
- (now_iso, int(five_hour_window_key), now_iso),
2183
+ "WHERE five_hour_window_key = ? AND account_key = ? "
2184
+ " AND is_closed = 0 AND five_hour_resets_at < ?",
2185
+ (now_iso, int(five_hour_window_key), account_key, now_iso),
2016
2186
  )
2017
2187
 
2018
2188
  # ── Replace-all per-tick: per-(block, model) and per-(block, project_path)
@@ -2021,8 +2191,9 @@ def maybe_update_five_hour_block(
2021
2191
  # Same transaction as the parent upsert; if these raise, the whole tick
2022
2192
  # rolls back and the next tick recomputes from scratch.
2023
2193
  conn.execute(
2024
- "DELETE FROM five_hour_block_models WHERE five_hour_window_key = ?",
2025
- (int(five_hour_window_key),),
2194
+ "DELETE FROM five_hour_block_models "
2195
+ "WHERE five_hour_window_key = ? AND account_key = ?",
2196
+ (int(five_hour_window_key), account_key),
2026
2197
  )
2027
2198
  if totals.get("by_model"):
2028
2199
  conn.executemany(
@@ -2031,9 +2202,9 @@ def maybe_update_five_hour_block(
2031
2202
  block_id, five_hour_window_key, model,
2032
2203
  input_tokens, output_tokens,
2033
2204
  cache_create_tokens, cache_read_tokens,
2034
- cost_usd, entry_count
2205
+ cost_usd, entry_count, account_key
2035
2206
  )
2036
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
2207
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2037
2208
  """,
2038
2209
  [
2039
2210
  (
@@ -2046,14 +2217,16 @@ def maybe_update_five_hour_block(
2046
2217
  b["cache_read_tokens"],
2047
2218
  b["cost_usd"],
2048
2219
  b["entry_count"],
2220
+ account_key,
2049
2221
  )
2050
2222
  for model, b in totals["by_model"].items()
2051
2223
  ],
2052
2224
  )
2053
2225
 
2054
2226
  conn.execute(
2055
- "DELETE FROM five_hour_block_projects WHERE five_hour_window_key = ?",
2056
- (int(five_hour_window_key),),
2227
+ "DELETE FROM five_hour_block_projects "
2228
+ "WHERE five_hour_window_key = ? AND account_key = ?",
2229
+ (int(five_hour_window_key), account_key),
2057
2230
  )
2058
2231
  if totals.get("by_project"):
2059
2232
  conn.executemany(
@@ -2062,9 +2235,9 @@ def maybe_update_five_hour_block(
2062
2235
  block_id, five_hour_window_key, project_path,
2063
2236
  input_tokens, output_tokens,
2064
2237
  cache_create_tokens, cache_read_tokens,
2065
- cost_usd, entry_count
2238
+ cost_usd, entry_count, account_key
2066
2239
  )
2067
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
2240
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2068
2241
  """,
2069
2242
  [
2070
2243
  (
@@ -2077,6 +2250,7 @@ def maybe_update_five_hour_block(
2077
2250
  b["cache_read_tokens"],
2078
2251
  b["cost_usd"],
2079
2252
  b["entry_count"],
2253
+ account_key,
2080
2254
  )
2081
2255
  for project_path, b in totals["by_project"].items()
2082
2256
  ],
@@ -2094,7 +2268,7 @@ def maybe_update_five_hour_block(
2094
2268
  # the active segment is the latest five_hour_reset_events row
2095
2269
  # for this window_key, else sentinel 0 (pre-credit).
2096
2270
  active_reset_event_id = _resolve_active_five_hour_reset_event_id(
2097
- conn, int(five_hour_window_key)
2271
+ conn, int(five_hour_window_key), account_key=account_key
2098
2272
  )
2099
2273
 
2100
2274
  if current_floor >= 1:
@@ -2110,8 +2284,9 @@ def maybe_update_five_hour_block(
2110
2284
  # maybe_record_milestone's max_existing path.
2111
2285
  row = conn.execute(
2112
2286
  "SELECT MAX(percent_threshold) AS m FROM five_hour_milestones "
2113
- "WHERE five_hour_window_key = ? AND reset_event_id = ?",
2114
- (int(five_hour_window_key), active_reset_event_id),
2287
+ "WHERE five_hour_window_key = ? AND reset_event_id = ? "
2288
+ " AND account_key = ?",
2289
+ (int(five_hour_window_key), active_reset_event_id, account_key),
2115
2290
  ).fetchone()
2116
2291
  max_existing = row["m"] if row and row["m"] is not None else None
2117
2292
 
@@ -2141,9 +2316,10 @@ def maybe_update_five_hour_block(
2141
2316
  "SELECT block_cost_usd FROM five_hour_milestones "
2142
2317
  "WHERE five_hour_window_key = ? "
2143
2318
  " AND percent_threshold = ? "
2144
- " AND reset_event_id = ?",
2319
+ " AND reset_event_id = ? "
2320
+ " AND account_key = ?",
2145
2321
  (int(five_hour_window_key), int(max_existing),
2146
- active_reset_event_id),
2322
+ active_reset_event_id, account_key),
2147
2323
  ).fetchone()
2148
2324
  if prev_row is not None:
2149
2325
  prior_cost = float(prev_row["block_cost_usd"])
@@ -2174,9 +2350,10 @@ def maybe_update_five_hour_block(
2174
2350
  block_cost_usd,
2175
2351
  marginal_cost_usd,
2176
2352
  seven_day_pct_at_crossing,
2177
- reset_event_id
2353
+ reset_event_id,
2354
+ account_key
2178
2355
  )
2179
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2356
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2180
2357
  """,
2181
2358
  (
2182
2359
  block_id,
@@ -2192,6 +2369,7 @@ def maybe_update_five_hour_block(
2192
2369
  marginal,
2193
2370
  weekly_percent,
2194
2371
  active_reset_event_id,
2372
+ account_key,
2195
2373
  ),
2196
2374
  )
2197
2375
  # ── Threshold-actions dispatch (set-then-dispatch, spec §3.2) ──
@@ -2230,9 +2408,10 @@ def maybe_update_five_hour_block(
2230
2408
  "WHERE five_hour_window_key = ? "
2231
2409
  " AND percent_threshold = ? "
2232
2410
  " AND reset_event_id = ? "
2411
+ " AND account_key = ? "
2233
2412
  " AND alerted_at IS NULL",
2234
2413
  (crossed_at, int(five_hour_window_key),
2235
- int(pct), active_reset_event_id),
2414
+ int(pct), active_reset_event_id, account_key),
2236
2415
  )
2237
2416
  # Cheap re-reads inside BEGIN are SELECT-only and
2238
2417
  # safe; values reflect post-INSERT state. We
@@ -2248,17 +2427,19 @@ def maybe_update_five_hour_block(
2248
2427
  "SELECT block_cost_usd FROM five_hour_milestones "
2249
2428
  "WHERE five_hour_window_key = ? "
2250
2429
  " AND percent_threshold = ? "
2251
- " AND reset_event_id = ?",
2430
+ " AND reset_event_id = ? "
2431
+ " AND account_key = ?",
2252
2432
  (int(five_hour_window_key), int(pct),
2253
- active_reset_event_id),
2433
+ active_reset_event_id, account_key),
2254
2434
  ).fetchone()
2255
2435
  block_row = conn.execute(
2256
2436
  "SELECT block_start_at FROM five_hour_blocks "
2257
- "WHERE five_hour_window_key = ?",
2258
- (int(five_hour_window_key),),
2437
+ "WHERE five_hour_window_key = ? AND account_key = ?",
2438
+ (int(five_hour_window_key), account_key),
2259
2439
  ).fetchone()
2260
2440
  primary_model = _resolve_primary_model_for_block(
2261
- conn, int(five_hour_window_key)
2441
+ conn, int(five_hour_window_key),
2442
+ account_key=account_key,
2262
2443
  )
2263
2444
  payload = _build_alert_payload_five_hour(
2264
2445
  threshold=int(pct),
@@ -2273,6 +2454,7 @@ def maybe_update_five_hour_block(
2273
2454
  else 0.0
2274
2455
  ),
2275
2456
  primary_model=primary_model,
2457
+ account_key=account_key,
2276
2458
  )
2277
2459
  pending_alerts.append(payload)
2278
2460
 
@@ -2320,16 +2502,19 @@ def maybe_update_five_hour_block(
2320
2502
  UPDATE five_hour_blocks
2321
2503
  SET crossed_seven_day_reset = 1
2322
2504
  WHERE crossed_seven_day_reset = 0
2505
+ AND account_key = ?
2323
2506
  AND (
2324
2507
  EXISTS (
2325
2508
  SELECT 1 FROM week_reset_events e
2326
- WHERE unixepoch(e.effective_reset_at_utc)
2509
+ WHERE e.account_key = five_hour_blocks.account_key
2510
+ AND unixepoch(e.effective_reset_at_utc)
2327
2511
  BETWEEN unixepoch(five_hour_blocks.block_start_at)
2328
2512
  AND unixepoch(five_hour_blocks.last_observed_at_utc)
2329
2513
  )
2330
2514
  OR EXISTS (
2331
2515
  SELECT 1 FROM weekly_usage_snapshots ws
2332
2516
  WHERE ws.week_start_at IS NOT NULL
2517
+ AND ws.account_key = five_hour_blocks.account_key
2333
2518
  AND unixepoch(ws.week_start_at)
2334
2519
  > unixepoch(five_hour_blocks.block_start_at)
2335
2520
  AND unixepoch(ws.week_start_at)
@@ -2337,6 +2522,7 @@ def maybe_update_five_hour_block(
2337
2522
  )
2338
2523
  )
2339
2524
  """,
2525
+ (account_key,),
2340
2526
  )
2341
2527
 
2342
2528
  if own_conn:
@@ -2458,11 +2644,15 @@ def _read_reset_zero_marker():
2458
2644
 
2459
2645
  def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
2460
2646
  *, observed_pre_credit_pct, effective_dt,
2461
- as_of=None, commit=True, ctx=None):
2647
+ as_of=None, commit=True, ctx=None,
2648
+ account_key=_lib_accounts.UNATTRIBUTED):
2462
2649
  """Emit/refresh the in-place weekly-credit artifacts (issue #19 + #128).
2463
2650
  Shared by the immediate >=25pp path and the debounced reset-to-zero
2464
2651
  confirmation path.
2465
2652
 
2653
+ ``account_key`` (#341): stamps the ``week_reset_events`` row and scopes the
2654
+ stale-replica capture/DELETE to the account.
2655
+
2466
2656
  Transaction-neutral / capture-time-pure seam (DB journal redesign §5.2.3):
2467
2657
  ``commit=False`` folds the event-row INSERT + stale-replica DELETE into the
2468
2658
  caller's transaction (the ingester's cycle) instead of committing inline;
@@ -2494,8 +2684,9 @@ def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
2494
2684
  # ticks. UNIQUE(old, new) also dedups, but the pre-check avoids a useless
2495
2685
  # write attempt and keeps logs clean.
2496
2686
  already = conn.execute(
2497
- "SELECT 1 FROM week_reset_events WHERE new_week_end_at = ? LIMIT 1",
2498
- (cur_end_canon,),
2687
+ "SELECT 1 FROM week_reset_events "
2688
+ "WHERE new_week_end_at = ? AND account_key = ? LIMIT 1",
2689
+ (cur_end_canon, account_key),
2499
2690
  ).fetchone()
2500
2691
  if already is None:
2501
2692
  # Row shape: old=effective_iso, new=cur_end_canon (DISTINCT) so only
@@ -2505,10 +2696,10 @@ def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
2505
2696
  ins_wr = conn.execute(
2506
2697
  "INSERT OR IGNORE INTO week_reset_events "
2507
2698
  "(detected_at_utc, old_week_end_at, new_week_end_at, "
2508
- " effective_reset_at_utc, observed_pre_credit_pct) "
2509
- "VALUES (?, ?, ?, ?, ?)",
2699
+ " effective_reset_at_utc, observed_pre_credit_pct, account_key) "
2700
+ "VALUES (?, ?, ?, ?, ?, ?)",
2510
2701
  (as_of or now_utc_iso(), effective_iso, cur_end_canon,
2511
- effective_iso, float(observed_pre_credit_pct)),
2702
+ effective_iso, float(observed_pre_credit_pct), account_key),
2512
2703
  )
2513
2704
  # Design B (§5.3 event+effects): on the ingest path, capture the doomed
2514
2705
  # stale-replica snapshots' journal_ids BEFORE the pivot-2 DELETE (SAME
@@ -2519,12 +2710,16 @@ def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
2519
2710
  if ctx is not None and ins_wr.rowcount == 1:
2520
2711
  doomed = conn.execute(
2521
2712
  "SELECT journal_id FROM weekly_usage_snapshots "
2522
- "WHERE week_start_date = ? "
2713
+ "WHERE week_start_date = ? AND account_key = ? "
2523
2714
  " AND unixepoch(captured_at_utc) >= unixepoch(?) "
2524
2715
  " AND ABS(weekly_percent - ?) < 1.0",
2525
- (week_start_date, effective_iso, float(observed_pre_credit_pct)),
2716
+ (week_start_date, account_key, effective_iso,
2717
+ float(observed_pre_credit_pct)),
2526
2718
  ).fetchall()
2527
- ctx.suppression_map[(effective_iso, cur_end_canon)] = [
2719
+ # #341: the wr harvest id_parts now lead with account_key, so the
2720
+ # suppression_map key (which _build_harvest_evt derives from id_parts)
2721
+ # must match: (account_key, old_week_end_at, new_week_end_at).
2722
+ ctx.suppression_map[(account_key, effective_iso, cur_end_canon)] = [
2528
2723
  r[0] for r in doomed
2529
2724
  ]
2530
2725
  if commit:
@@ -2546,10 +2741,11 @@ def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
2546
2741
  try:
2547
2742
  conn.execute(
2548
2743
  "DELETE FROM weekly_usage_snapshots "
2549
- "WHERE week_start_date = ? "
2744
+ "WHERE week_start_date = ? AND account_key = ? "
2550
2745
  " AND unixepoch(captured_at_utc) >= unixepoch(?) "
2551
2746
  " AND ABS(weekly_percent - ?) < 1.0",
2552
- (week_start_date, effective_iso, float(observed_pre_credit_pct)),
2747
+ (week_start_date, account_key, effective_iso,
2748
+ float(observed_pre_credit_pct)),
2553
2749
  )
2554
2750
  if commit:
2555
2751
  conn.commit()
@@ -2560,11 +2756,17 @@ def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
2560
2756
  def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
2561
2757
  weekly_percent, five_hour_window_key,
2562
2758
  five_hour_percent, as_of=None, commit=True,
2563
- ctx=None):
2759
+ ctx=None, account_key=_lib_accounts.UNATTRIBUTED):
2564
2760
  """Detect + record weekly and 5h reset/credit artifacts for one usage
2565
2761
  observation (extracted from ``cmd_record_usage``; DB journal redesign
2566
2762
  §5.2.3).
2567
2763
 
2764
+ ``account_key`` (#341): the account the observation belongs to. Every
2765
+ reset/credit row written here (``week_reset_events``,
2766
+ ``five_hour_reset_events``, ``weekly_credit_floors``, synthetic snapshots)
2767
+ is stamped with it so the reset-aware clamp legs stay account-scoped.
2768
+ Default ``"unattributed"`` is the rev-4.1 defensive backstop.
2769
+
2568
2770
  Runs the mid-week ``week_reset_events`` detection, the reset-to-zero
2569
2771
  debounce + >=25pp in-place-credit fire, and the parallel 5h in-place-
2570
2772
  credit detection, all on the caller's ``conn``.
@@ -2644,9 +2846,10 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
2644
2846
  conn.execute(
2645
2847
  "INSERT OR IGNORE INTO week_reset_events "
2646
2848
  "(detected_at_utc, old_week_end_at, new_week_end_at, "
2647
- " effective_reset_at_utc) VALUES (?, ?, ?, ?)",
2849
+ " effective_reset_at_utc, account_key) "
2850
+ "VALUES (?, ?, ?, ?, ?)",
2648
2851
  ((as_of or now_utc_iso()), prior_end_canon, cur_end_canon,
2649
- effective_iso),
2852
+ effective_iso, account_key),
2650
2853
  )
2651
2854
  # (inline commit removed — the function commits once at the
2652
2855
  # end on the legacy path; the ingest cycle owns the commit.)
@@ -2692,6 +2895,7 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
2692
2895
  observed_pre_credit_pct=float(prior_pct),
2693
2896
  effective_dt=_floor_to_hour(now_utc),
2694
2897
  as_of=as_of, commit=commit, ctx=ctx,
2898
+ account_key=account_key,
2695
2899
  )
2696
2900
  elif decision == CONFIRM_RESET:
2697
2901
  # Second reading stayed low → confirm. Anchor the
@@ -2706,6 +2910,7 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
2706
2910
  observed_pre_credit_pct=marker[2],
2707
2911
  effective_dt=_floor_to_hour(first_zero_dt),
2708
2912
  as_of=as_of, commit=commit, ctx=ctx,
2913
+ account_key=account_key,
2709
2914
  )
2710
2915
  # Clear ONLY after the fire completes (P2a): a
2711
2916
  # mid-fire crash leaves the marker armed so the next
@@ -2811,8 +3016,9 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
2811
3016
  "SELECT prior_percent, post_percent "
2812
3017
  " FROM five_hour_reset_events "
2813
3018
  " WHERE five_hour_window_key = ? "
3019
+ " AND account_key = ? "
2814
3020
  " ORDER BY id DESC LIMIT 1",
2815
- (int(five_hour_window_key),),
3021
+ (int(five_hour_window_key), account_key),
2816
3022
  ).fetchone()
2817
3023
  is_dup = (
2818
3024
  most_recent is not None
@@ -2846,14 +3052,15 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
2846
3052
  "INSERT OR IGNORE INTO five_hour_reset_events "
2847
3053
  "(detected_at_utc, five_hour_window_key, "
2848
3054
  " prior_percent, post_percent, "
2849
- " effective_reset_at_utc) "
2850
- "VALUES (?, ?, ?, ?, ?)",
3055
+ " effective_reset_at_utc, account_key) "
3056
+ "VALUES (?, ?, ?, ?, ?, ?)",
2851
3057
  (
2852
3058
  (as_of or now_utc_iso()),
2853
3059
  int(five_hour_window_key),
2854
3060
  prior_5h_pct,
2855
3061
  float(five_hour_percent),
2856
3062
  effective_iso,
3063
+ account_key,
2857
3064
  ),
2858
3065
  )
2859
3066
  # Design B (§5.3 event+effects): on the ingest path,
@@ -2873,18 +3080,23 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
2873
3080
  "SELECT journal_id "
2874
3081
  "FROM weekly_usage_snapshots "
2875
3082
  " WHERE five_hour_window_key = ? "
3083
+ " AND account_key = ? "
2876
3084
  " AND unixepoch(captured_at_utc) "
2877
3085
  " >= unixepoch(?) "
2878
3086
  " AND ABS(five_hour_percent - ?) "
2879
3087
  " < 1.0",
2880
3088
  (
2881
3089
  int(five_hour_window_key),
3090
+ account_key,
2882
3091
  effective_iso,
2883
3092
  prior_5h_pct,
2884
3093
  ),
2885
3094
  ).fetchall()
3095
+ # #341: fhc harvest id_parts lead with
3096
+ # account_key -> match the suppression_map key.
2886
3097
  ctx.suppression_map[
2887
- (int(five_hour_window_key), effective_iso)
3098
+ (account_key, int(five_hour_window_key),
3099
+ effective_iso)
2888
3100
  ] = [r[0] for r in doomed]
2889
3101
  # (inline commit removed — one end-of-function commit
2890
3102
  # on legacy; the ingest cycle owns the commit.)
@@ -2960,12 +3172,14 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
2960
3172
  conn.execute(
2961
3173
  "DELETE FROM weekly_usage_snapshots "
2962
3174
  " WHERE five_hour_window_key = ? "
3175
+ " AND account_key = ? "
2963
3176
  " AND unixepoch(captured_at_utc) "
2964
3177
  " >= unixepoch(?) "
2965
3178
  " AND ABS(five_hour_percent - ?) "
2966
3179
  " < 1.0",
2967
3180
  (
2968
3181
  int(five_hour_window_key),
3182
+ account_key,
2969
3183
  effective_iso,
2970
3184
  prior_5h_pct,
2971
3185
  ),
@@ -2999,36 +3213,49 @@ def detect_reset_and_credit(conn, *, week_start_date, week_end_at,
2999
3213
  conn.commit()
3000
3214
 
3001
3215
 
3002
- def _resolve_reset_aware_hwm(conn, week_start_date, week_start_at, week_end_at):
3216
+ def _resolve_reset_aware_hwm(conn, week_start_date, week_start_at, week_end_at,
3217
+ *, account_key):
3003
3218
  """The floored MAX(weekly_percent) the statusline _hwm_clamp computes:
3004
3219
  MAX over snapshots captured at/after the latest in-week clamp floor. The
3005
3220
  floor is the latest effective across BOTH `week_reset_events` and
3006
3221
  `weekly_credit_floors` (`_reset_aware_floor`) so a manual partial credit
3007
3222
  (record-credit M2, #209) lowers the resolved HWM without re-anchoring the
3008
3223
  week — used both as the `--from` default and as the assertion source of
3009
- truth in the record-credit tests."""
3010
- floor_iso = _reset_aware_floor(conn, week_start_date, week_start_at, week_end_at)
3224
+ truth in the record-credit tests.
3225
+
3226
+ ``account_key`` (#341): MANDATORY account context (no silent global
3227
+ fallback). A real key scopes the HWM to one account; ``None`` is the
3228
+ explicit merged read (byte-identical to today on a single-account install)."""
3229
+ floor_iso = _reset_aware_floor(conn, week_start_date, week_start_at,
3230
+ week_end_at, account_key=account_key)
3231
+ acct_pred = "" if account_key is None else " AND account_key = ?"
3232
+ acct_param: tuple = () if account_key is None else (account_key,)
3011
3233
  if floor_iso is not None:
3012
3234
  row = conn.execute(
3013
3235
  "SELECT MAX(weekly_percent) FROM weekly_usage_snapshots "
3014
- " WHERE week_start_date = ? AND unixepoch(captured_at_utc) >= unixepoch(?)",
3015
- (week_start_date, floor_iso),
3236
+ f" WHERE week_start_date = ?{acct_pred} "
3237
+ " AND unixepoch(captured_at_utc) >= unixepoch(?)",
3238
+ (week_start_date, *acct_param, floor_iso),
3016
3239
  ).fetchone()
3017
3240
  else:
3018
3241
  row = conn.execute(
3019
3242
  "SELECT MAX(weekly_percent) FROM weekly_usage_snapshots "
3020
- " WHERE week_start_date = ?",
3021
- (week_start_date,),
3243
+ f" WHERE week_start_date = ?{acct_pred}",
3244
+ (week_start_date, *acct_param),
3022
3245
  ).fetchone()
3023
3246
  return None if not row or row[0] is None else float(row[0])
3024
3247
 
3025
3248
 
3026
3249
  def _insert_credit_snapshot(conn, plan, *, five_hour=(None, None, None),
3027
- commit=True, journal=None):
3250
+ commit=True, journal=None,
3251
+ account_key=_lib_accounts.UNATTRIBUTED):
3028
3252
  """Insert the post-credit synthetic snapshot at plan.to_pct, tagged
3029
3253
  source='record-credit'. The SOLE synthetic-snapshot writer on BOTH paths
3030
3254
  (legacy inline + ingest), so the non-vacuity stub stays load-bearing.
3031
3255
 
3256
+ ``account_key`` (#341) stamps the synthetic row so it joins the same
3257
+ account's clamp/HWM ledger.
3258
+
3032
3259
  ``commit=False`` (DB journal redesign §5.2.3) folds the synthetic-snapshot
3033
3260
  INSERT into the caller's transaction instead of committing inline; the
3034
3261
  default keeps the legacy inline-commit behavior. Capture time comes from
@@ -3065,6 +3292,7 @@ def _insert_credit_snapshot(conn, plan, *, five_hour=(None, None, None),
3065
3292
  "five_hour_percent": fhp,
3066
3293
  "five_hour_resets_at": fhr,
3067
3294
  "five_hour_window_key": fhk,
3295
+ "account_key": account_key,
3068
3296
  }
3069
3297
  if journal is not None:
3070
3298
  ctx, evt_id = journal
@@ -3105,12 +3333,16 @@ def _resolve_prior_5h(conn, at_dt):
3105
3333
 
3106
3334
 
3107
3335
  def _apply_credit(conn, plan, *, five_hour=(None, None, None), as_of=None,
3108
- commit=True, ctx=None, id_base=None, forced=False):
3336
+ commit=True, ctx=None, id_base=None, forced=False,
3337
+ account_key=_lib_accounts.UNATTRIBUTED):
3109
3338
  """Apply the M2 same-window partial-credit artifacts (record-credit, #209,
3110
3339
  spec §4). Unlike the >=25pp auto-credit path (`_fire_in_place_credit`), this
3111
3340
  writes NO `week_reset_events` row — the window-resolution code never sees a
3112
3341
  credit, so the week is NOT re-anchored. It only lowers the clamp floor.
3113
3342
 
3343
+ ``account_key`` (#341): stamps the ``weekly_credit_floors`` row + the
3344
+ synthetic snapshot and scopes the stale-replay DELETE to the account.
3345
+
3114
3346
  Transaction-neutral / capture-time-pure seam (DB journal redesign §5.2.3):
3115
3347
  ``commit=False`` folds the floor INSERT + stale-replay DELETE + synthetic
3116
3348
  snapshot into the caller's transaction (the ingester's cycle) instead of
@@ -3175,9 +3407,11 @@ def _apply_credit(conn, plan, *, five_hour=(None, None, None), as_of=None,
3175
3407
  if ctx is None:
3176
3408
  conn.execute(
3177
3409
  "INSERT OR IGNORE INTO weekly_credit_floors "
3178
- "(week_start_date, effective_at_utc, observed_pre_credit_pct, applied_at_utc) "
3179
- "VALUES (?, ?, ?, ?)",
3180
- (plan.week_start_date, effective_iso, pre_credit, as_of or now_utc_iso()),
3410
+ "(week_start_date, effective_at_utc, observed_pre_credit_pct, "
3411
+ " applied_at_utc, account_key) "
3412
+ "VALUES (?, ?, ?, ?, ?)",
3413
+ (plan.week_start_date, effective_iso, pre_credit,
3414
+ as_of or now_utc_iso(), account_key),
3181
3415
  )
3182
3416
  if commit:
3183
3417
  conn.commit()
@@ -3201,10 +3435,10 @@ def _apply_credit(conn, plan, *, five_hour=(None, None, None), as_of=None,
3201
3435
  try:
3202
3436
  conn.execute(
3203
3437
  "DELETE FROM weekly_usage_snapshots "
3204
- "WHERE week_start_date = ? "
3438
+ "WHERE week_start_date = ? AND account_key = ? "
3205
3439
  " AND unixepoch(captured_at_utc) >= unixepoch(?) "
3206
3440
  " AND ABS(weekly_percent - ?) < 1.0",
3207
- (plan.week_start_date, effective_iso, pre_credit),
3441
+ (plan.week_start_date, account_key, effective_iso, pre_credit),
3208
3442
  )
3209
3443
  if commit:
3210
3444
  conn.commit()
@@ -3212,7 +3446,8 @@ def _apply_credit(conn, plan, *, five_hour=(None, None, None), as_of=None,
3212
3446
  eprint(f"[record-credit] post-credit cleanup failed: {exc}")
3213
3447
 
3214
3448
  # 4d. INSERT the synthetic post-credit snapshot at plan.to_pct.
3215
- c._insert_credit_snapshot(conn, plan, five_hour=five_hour, commit=commit)
3449
+ c._insert_credit_snapshot(conn, plan, five_hour=five_hour, commit=commit,
3450
+ account_key=account_key)
3216
3451
  else:
3217
3452
  # ── Ingest path (Design B, §5.3 event+effects) ─────────────────────
3218
3453
  # Journal the destructive effects + synthetic instead of running them
@@ -3235,12 +3470,13 @@ def _apply_credit(conn, plan, *, five_hour=(None, None, None), as_of=None,
3235
3470
  # (`id_base` is a content digest `o:<hex>`, no LIKE metacharacters.)
3236
3471
  doomed = conn.execute(
3237
3472
  "SELECT journal_id FROM weekly_usage_snapshots "
3238
- "WHERE week_start_date = ? "
3473
+ "WHERE week_start_date = ? AND account_key = ? "
3239
3474
  " AND unixepoch(captured_at_utc) >= unixepoch(?) "
3240
3475
  " AND ABS(weekly_percent - ?) < 1.0 "
3241
3476
  " AND journal_id IS NOT NULL "
3242
3477
  " AND journal_id NOT LIKE 'sa:' || ? || ':syn:%'",
3243
- (plan.week_start_date, effective_iso, pre_credit, id_base),
3478
+ (plan.week_start_date, account_key, effective_iso, pre_credit,
3479
+ id_base),
3244
3480
  ).fetchall()
3245
3481
  supp = [r[0] for r in doomed]
3246
3482
  floor_supp: list = []
@@ -3267,17 +3503,18 @@ def _apply_credit(conn, plan, *, five_hour=(None, None, None), as_of=None,
3267
3503
  # content digest `o:<hex>`, no LIKE metacharacters.)
3268
3504
  old_syn = conn.execute(
3269
3505
  "SELECT journal_id FROM weekly_usage_snapshots "
3270
- "WHERE week_start_date = ? AND source = 'record-credit' "
3506
+ "WHERE week_start_date = ? AND account_key = ? "
3507
+ " AND source = 'record-credit' "
3271
3508
  " AND journal_id IS NOT NULL "
3272
3509
  " AND journal_id NOT LIKE 'sa:' || ? || ':syn:%'",
3273
- (plan.week_start_date, id_base),
3510
+ (plan.week_start_date, account_key, id_base),
3274
3511
  ).fetchall()
3275
3512
  supp.extend(r[0] for r in old_syn if r[0] not in supp)
3276
3513
  old_floors = conn.execute(
3277
3514
  "SELECT journal_id FROM weekly_credit_floors "
3278
- "WHERE week_start_date = ? AND journal_id IS NOT NULL "
3279
- " AND journal_id != ?",
3280
- (plan.week_start_date, id_base),
3515
+ "WHERE week_start_date = ? AND account_key = ? "
3516
+ " AND journal_id IS NOT NULL AND journal_id != ?",
3517
+ (plan.week_start_date, account_key, id_base),
3281
3518
  ).fetchall()
3282
3519
  floor_supp = [r[0] for r in old_floors]
3283
3520
  # wce evt (effects-only, table=None): snapshot suppression list + floor
@@ -3307,7 +3544,7 @@ def _apply_credit(conn, plan, *, five_hour=(None, None, None), as_of=None,
3307
3544
  # load-bearing (stubbing it disables the synthetic on the ingest path too).
3308
3545
  c._insert_credit_snapshot(
3309
3546
  conn, plan, five_hour=five_hour, commit=False,
3310
- journal=(ctx, f"sa:{id_base}:syn:0"))
3547
+ journal=(ctx, f"sa:{id_base}:syn:0"), account_key=account_key)
3311
3548
 
3312
3549
  # 4e. Clear a stale same-week reset-zero marker so the next record-usage
3313
3550
  # tick can't confirm a phantom reset-to-zero off it.
@@ -3426,7 +3663,7 @@ def _revalidate_credit_plan(conn, args, *, now, at_dt, expected_plan):
3426
3663
  elif existing is not None and existing[2] is not None:
3427
3664
  from_pct, from_source = float(existing[2]), "prior_credit"
3428
3665
  else:
3429
- from_pct = _resolve_reset_aware_hwm(conn, week_start_date, ws_at, we_at)
3666
+ from_pct = _resolve_reset_aware_hwm(conn, week_start_date, ws_at, we_at, account_key=None)
3430
3667
  if from_pct is None:
3431
3668
  return None
3432
3669
  from_source = "hwm"
@@ -3514,7 +3751,7 @@ def cmd_record_credit(args) -> int:
3514
3751
  # is 'prior_credit' (spec §5).
3515
3752
  from_pct, from_source = float(existing[2]), "prior_credit"
3516
3753
  else:
3517
- hwm = _resolve_reset_aware_hwm(conn, week_start_date, ws_at, we_at)
3754
+ hwm = _resolve_reset_aware_hwm(conn, week_start_date, ws_at, we_at, account_key=None)
3518
3755
  if hwm is None:
3519
3756
  eprint("record-credit: no usage history for the week; pass --from")
3520
3757
  return 2
@@ -3562,7 +3799,7 @@ def cmd_record_credit(args) -> int:
3562
3799
  is_dry = getattr(args, "dry_run", False)
3563
3800
  is_yes = getattr(args, "yes", False)
3564
3801
  stale_replays = _count_stale_replays(conn, plan)
3565
- hwm_before = _resolve_reset_aware_hwm(conn, week_start_date, ws_at, we_at)
3802
+ hwm_before = _resolve_reset_aware_hwm(conn, week_start_date, ws_at, we_at, account_key=None)
3566
3803
  if hwm_before is None:
3567
3804
  hwm_before = plan.from_pct
3568
3805
 
@@ -3638,7 +3875,8 @@ def cmd_record_credit(args) -> int:
3638
3875
  plan, existing, is_completion = revalidated
3639
3876
  stale_replays = _count_stale_replays(conn, plan)
3640
3877
  hwm_before = _resolve_reset_aware_hwm(
3641
- conn, plan.week_start_date, plan.week_start_at, plan.week_end_at
3878
+ conn, plan.week_start_date, plan.week_start_at, plan.week_end_at,
3879
+ account_key=None
3642
3880
  )
3643
3881
  if hwm_before is None:
3644
3882
  hwm_before = plan.from_pct
@@ -3670,6 +3908,22 @@ def cmd_record_credit(args) -> int:
3670
3908
  effective_utc = parse_iso_datetime(
3671
3909
  plan.effective_iso, "op.effective"
3672
3910
  ).astimezone(dt.timezone.utc).isoformat(timespec="seconds")
3911
+ # Active-account gate (#341 P2-1, spec §3): record-credit is
3912
+ # active-account-only. Resolve the active Claude account and stamp
3913
+ # the credit op so the floor lands under the SAME account post-Step-9
3914
+ # usage carries — else the account-scoped `_reset_aware_floor` clamp
3915
+ # would never see a real credit floor. A TORN read (transient
3916
+ # mid-write ~/.claude.json) means the active identity is genuinely
3917
+ # unavailable -> exit 2 (retry). A stably-absent read (no
3918
+ # ~/.claude.json / api-key mode) is a RESOLVED `unattributed` outcome
3919
+ # (single-account / legacy install), NOT unavailable -> proceed
3920
+ # byte-identically to pre-#341.
3921
+ identity = _cctally_core._resolve_active_claude_identity()
3922
+ if identity.get("status") == "torn":
3923
+ eprint("record-credit: active Claude account is unavailable "
3924
+ "(torn read of ~/.claude.json); retry once it settles")
3925
+ return 2
3926
+ credit_account_key = identity["account_key"]
3673
3927
  import _cctally_journal as _jr
3674
3928
  import _lib_journal as _lj
3675
3929
  op = _lj.make_op(
@@ -3684,6 +3938,9 @@ def cmd_record_credit(args) -> int:
3684
3938
  "plan": dict(vars(plan)),
3685
3939
  "five_hour": list(five_hour),
3686
3940
  "forced": forced,
3941
+ # Two-shaped stamp (#341): evt/op carry account_key in the
3942
+ # payload; the op fold + `_apply_credit` both read it.
3943
+ "account_key": credit_account_key,
3687
3944
  },
3688
3945
  )
3689
3946
  # Close the command's read connection BEFORE the ingester (the sole
@@ -3857,14 +4114,63 @@ def cmd_record_usage(
3857
4114
 
3858
4115
  import _cctally_journal as _jr
3859
4116
  import _lib_journal as _lj
4117
+ import _lib_accounts
4118
+ # Observe-and-stamp (#341, spec §1): resolve the active Claude account from
4119
+ # ~/.claude.json (stable-read, mtime-cached) and stamp the usage obs with it.
4120
+ # Byte-safe: a real account is stamped explicitly; the reserved sentinel
4121
+ # (no ~/.claude.json / api-key / torn) OMITS the field, so a single-account /
4122
+ # legacy install's journal obs stays byte-identical to today (the pipeline's
4123
+ # `rec.get("account") or UNATTRIBUTED` fallback treats both the same).
4124
+ obs_at = now_utc_iso(now_dt)
4125
+ identity = _cctally_core._resolve_active_claude_identity()
4126
+ account_key = identity["account_key"]
4127
+ obs_account = None if account_key == _lib_accounts.UNATTRIBUTED else account_key
4128
+ if obs_account is not None:
4129
+ # Journal the account_observe DURABLY BEFORE the account-stamped usage obs
4130
+ # (spec §1: replay can never see a stamped row whose account was never
4131
+ # observed). First-sight/identity-change only — marker-deduped so it is
4132
+ # not appended every tick.
4133
+ _maybe_append_account_observe(identity, at=obs_at)
3860
4134
  _jr.append_record(_lj.make_obs(
3861
- at=now_utc_iso(now_dt), src=writer, provider="claude", payload=raw))
4135
+ at=obs_at, src=writer, provider="claude", payload=raw,
4136
+ account=obs_account))
3862
4137
  # authoritative observes its own write synchronously; opportunistic skips a
3863
4138
  # busy ingest lock and lets the current holder consume the appended line.
3864
4139
  _jr.run_stats_ingest(mode=ingest_mode)
3865
4140
  return 0
3866
4141
 
3867
4142
 
4143
+ _OBSERVED_ACCOUNT_MARKER = "observed-claude-account"
4144
+
4145
+
4146
+ def _maybe_append_account_observe(identity: dict, *, at: str) -> None:
4147
+ """Append an ``account_observe`` op on first sight of a Claude account or an
4148
+ identity change (#341, spec §1) — NOT every tick. Deduped by a marker file in
4149
+ APP_DIR so a hot status-line loop journals at most one observe per account.
4150
+ Best-effort: a marker/journal hiccup never breaks record-usage."""
4151
+ import _lib_accounts
4152
+ account_key = identity.get("account_key")
4153
+ if not account_key or account_key == _lib_accounts.UNATTRIBUTED:
4154
+ return
4155
+ marker = _cctally_core.APP_DIR / _OBSERVED_ACCOUNT_MARKER
4156
+ try:
4157
+ last = marker.read_text().strip()
4158
+ except OSError:
4159
+ last = None
4160
+ if last == account_key:
4161
+ return
4162
+ try:
4163
+ import _cctally_journal as _jr
4164
+ import _lib_journal as _lj
4165
+ _jr.append_record(_lj.make_account_observe(
4166
+ at=at, account_key=account_key, provider="claude",
4167
+ natural_id=identity.get("natural_id"), email=identity.get("email"),
4168
+ plan_type=identity.get("plan_type"), label_source="auto"))
4169
+ marker.write_text(account_key + "\n")
4170
+ except OSError:
4171
+ pass
4172
+
4173
+
3868
4174
  def _hook_tick_log_line(line: str) -> None:
3869
4175
  """Append one line to hook-tick.log; create dir if missing.
3870
4176
 
@@ -4227,7 +4533,13 @@ def _cmd_hook_tick_codex(args: argparse.Namespace, *, event: str = "unknown") ->
4227
4533
  contextlib.redirect_stdout(quiet), contextlib.redirect_stderr(quiet):
4228
4534
  cache = c.open_cache_db()
4229
4535
  try:
4230
- stats = c.sync_codex_cache(cache, lock_timeout=0)
4536
+ cache_mod = c._load_sibling("_cctally_cache")
4537
+ stats, cache = cache_mod._run_cache_operation_with_recovery(
4538
+ cache,
4539
+ lambda active_conn: c.sync_codex_cache(
4540
+ active_conn, lock_timeout=0
4541
+ ),
4542
+ )
4231
4543
  finally:
4232
4544
  cache.close()
4233
4545
  if stats.lock_contended:
@@ -4400,7 +4712,13 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
4400
4712
  try:
4401
4713
  cache_conn = open_cache_db()
4402
4714
  try:
4403
- stats = sync_cache(cache_conn)
4715
+ cache_mod = _cctally()._load_sibling("_cctally_cache")
4716
+ stats, cache_conn = (
4717
+ cache_mod._run_cache_operation_with_recovery(
4718
+ cache_conn,
4719
+ lambda active_conn: sync_cache(active_conn),
4720
+ )
4721
+ )
4404
4722
  ingested = int(stats.rows_changed)
4405
4723
  parse_malformed = int(stats.lines_malformed)
4406
4724
  parse_skipped = int(stats.assistant_lines_skipped)
@@ -4610,6 +4928,7 @@ _USAGE_SNAPSHOT_COLUMNS = (
4610
4928
  "captured_at_utc", "week_start_date", "week_end_date", "week_start_at",
4611
4929
  "week_end_at", "weekly_percent", "page_url", "source", "payload_json",
4612
4930
  "five_hour_percent", "five_hour_resets_at", "five_hour_window_key",
4931
+ "account_key",
4613
4932
  )
4614
4933
 
4615
4934
 
@@ -4685,6 +5004,10 @@ def _usage_snapshot_columns(conn, payload, week_start_name):
4685
5004
  "five_hour_percent": five_hour_percent,
4686
5005
  "five_hour_resets_at": five_hour_resets_at,
4687
5006
  "five_hour_window_key": five_hour_window_key,
5007
+ # Account dimension (#341): carried from the payload when present (the
5008
+ # snapshot_accept emit path threads the resolved account through here);
5009
+ # defaults to the reserved sentinel for the bare test-only insert path.
5010
+ "account_key": payload.get("account_key") or "unattributed",
4688
5011
  }
4689
5012
 
4690
5013
  saved = {
@@ -4727,9 +5050,10 @@ def insert_usage_snapshot(payload: dict[str, Any], week_start_name: str) -> dict
4727
5050
  payload_json,
4728
5051
  five_hour_percent,
4729
5052
  five_hour_resets_at,
4730
- five_hour_window_key
5053
+ five_hour_window_key,
5054
+ account_key
4731
5055
  )
4732
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5056
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4733
5057
  """,
4734
5058
  tuple(cols[k] for k in _USAGE_SNAPSHOT_COLUMNS),
4735
5059
  )
@@ -4970,6 +5294,12 @@ def _pipeline_claude_usage(ctx, rec):
4970
5294
  weekly_percent = float(payload["weekly_percent"])
4971
5295
  resets_at = int(payload["resets_at"])
4972
5296
  source = payload.get("source", "statusline")
5297
+ # Account dimension (#341): the obs carries the account stamp (record-usage /
5298
+ # statusline resolve it once per read, Step 9). Absent (pre-multi-account /
5299
+ # legacy obs) -> the reserved sentinel, which keeps every scoped query
5300
+ # byte-identical on a single-account install.
5301
+ import _lib_accounts
5302
+ account_key = rec.get("account") or _lib_accounts.UNATTRIBUTED
4973
5303
 
4974
5304
  # Week boundaries from resets_at (cmd_record_usage canonicalization).
4975
5305
  week_end_at_dt = dt.datetime.fromtimestamp(resets_at, tz=dt.timezone.utc)
@@ -5004,6 +5334,7 @@ def _pipeline_claude_usage(ctx, rec):
5004
5334
  as_of=as_of,
5005
5335
  commit=False,
5006
5336
  ctx=ctx,
5337
+ account_key=account_key,
5007
5338
  )
5008
5339
 
5009
5340
  # 2. Accept/skip DECISION (clamp + dedup), made ONCE and journaled via the
@@ -5016,6 +5347,7 @@ def _pipeline_claude_usage(ctx, rec):
5016
5347
  "weekly_percent": weekly_percent,
5017
5348
  "five_hour_percent": five_hour_percent,
5018
5349
  "five_hour_window_key": five_hour_window_key,
5350
+ "account_key": account_key,
5019
5351
  })
5020
5352
  five_hour_percent = adjusted_5h
5021
5353
 
@@ -5046,6 +5378,9 @@ def _pipeline_claude_usage(ctx, rec):
5046
5378
 
5047
5379
  week_start_name = get_week_start_name(ctx.config or {}, None)
5048
5380
  cols, saved = _usage_snapshot_columns(conn, out_payload, week_start_name)
5381
+ # Stamp the account onto the snapshot_accept evt columns so the journaled
5382
+ # row (and every replay/rebuild fold of it) carries the account (#341).
5383
+ cols["account_key"] = account_key
5049
5384
  rowid = jr.emit_model_a(
5050
5385
  ctx,
5051
5386
  kind="snapshot_accept",
@@ -5058,8 +5393,9 @@ def _pipeline_claude_usage(ctx, rec):
5058
5393
  else:
5059
5394
  latest = conn.execute(
5060
5395
  "SELECT * FROM weekly_usage_snapshots WHERE week_start_date = ? "
5396
+ " AND account_key = ? "
5061
5397
  "ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
5062
- (week_start_date,),
5398
+ (week_start_date, account_key),
5063
5399
  ).fetchone()
5064
5400
  if latest is None:
5065
5401
  return # nothing recorded yet -> nothing to derive against
@@ -5073,9 +5409,10 @@ def _pipeline_claude_usage(ctx, rec):
5073
5409
  # crosses a $ threshold still derives it.
5074
5410
  c.maybe_record_milestone(
5075
5411
  saved, conn=conn, as_of=capture_at, alert_sink=ctx.pending_alerts,
5076
- journal=(ctx, rec["id"]))
5412
+ journal=(ctx, rec["id"]), account_key=account_key)
5077
5413
  c.maybe_update_five_hour_block(
5078
- saved, conn=conn, as_of=capture_at, alert_sink=ctx.pending_alerts)
5414
+ saved, conn=conn, as_of=capture_at, alert_sink=ctx.pending_alerts,
5415
+ account_key=account_key)
5079
5416
  # The dollar-decoupled axes resolve a CURRENT budget/period WINDOW from
5080
5417
  # "now" and must see the record's DETECTION clock (`as_of` = rec["at"] =
5081
5418
  # `_command_as_of()`), NOT the wall-clock capture stamp: legacy
@@ -5107,7 +5444,8 @@ def _pipeline_claude_usage(ctx, rec):
5107
5444
  if latest_wk is None or int(latest_wk) != int(five_hour_window_key):
5108
5445
  if conn.execute(
5109
5446
  "SELECT 1 FROM five_hour_blocks WHERE five_hour_window_key = ? "
5110
- "LIMIT 1", (int(five_hour_window_key),),
5447
+ " AND account_key = ? LIMIT 1",
5448
+ (int(five_hour_window_key), account_key),
5111
5449
  ).fetchone() is None:
5112
5450
  c.maybe_update_five_hour_block(
5113
5451
  {
@@ -5118,7 +5456,8 @@ def _pipeline_claude_usage(ctx, rec):
5118
5456
  "fiveHourResetsAt": five_hour_resets_at_str,
5119
5457
  "fiveHourWindowKey": int(five_hour_window_key),
5120
5458
  },
5121
- conn=conn, as_of=capture_at, alert_sink=ctx.pending_alerts)
5459
+ conn=conn, as_of=capture_at, alert_sink=ctx.pending_alerts,
5460
+ account_key=account_key)
5122
5461
 
5123
5462
  # 5. hwm-7d / hwm-5h projection files — ACCEPT path only (the monotonic
5124
5463
  # writer; a dedup tick's percent is already <= the stored HWM). In-place
@@ -5156,10 +5495,15 @@ def _pipeline_record_credit(ctx, rec):
5156
5495
  c = _cctally()
5157
5496
  plan = argparse.Namespace(**plan_data)
5158
5497
  five_hour = tuple(payload.get("five_hour") or (None, None, None))
5498
+ # Two-shaped stamp (#341): the record-credit op carries account_key in its
5499
+ # payload (Step 9 / Task 3 resolves the active account before appending it);
5500
+ # default to the sentinel for a legacy op written pre-#341.
5501
+ import _lib_accounts
5159
5502
  c._apply_credit(
5160
5503
  ctx.conn, plan, five_hour=five_hour, as_of=rec["at"],
5161
5504
  commit=False, ctx=ctx, id_base=rec["id"],
5162
- forced=bool(payload.get("forced")))
5505
+ forced=bool(payload.get("forced")),
5506
+ account_key=(payload.get("account_key") or _lib_accounts.UNATTRIBUTED))
5163
5507
 
5164
5508
 
5165
5509
  def _pipeline_sync_week(ctx, rec):
@@ -5184,8 +5528,14 @@ def _pipeline_sync_week(ctx, rec):
5184
5528
  json=False,
5185
5529
  quiet=True,
5186
5530
  )
5531
+ # Two-shaped stamp (#341 P2-1): the sync_week op carries the active account
5532
+ # in its payload; the fold stamps the cost snapshot under it (rebuild-
5533
+ # deterministic). Absent -> the sentinel (single-account / legacy op).
5534
+ import _lib_accounts
5187
5535
  c.cmd_sync_week(args, conn=ctx.conn, as_of=rec["at"],
5188
- journal=(ctx, rec["id"]))
5536
+ journal=(ctx, rec["id"]),
5537
+ account_key=(payload.get("account_key")
5538
+ or _lib_accounts.UNATTRIBUTED))
5189
5539
 
5190
5540
 
5191
5541
  def _register_ingest_pipeline_hooks():