cctally 1.117.0 → 1.117.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.
@@ -29,7 +29,9 @@ import sqlite3
29
29
  import sys
30
30
  from dataclasses import replace
31
31
 
32
- from _cctally_core import make_week_ref, parse_iso_datetime
32
+ from _cctally_core import (
33
+ _canonicalize_optional_iso, make_week_ref, parse_iso_datetime,
34
+ )
33
35
  from _cctally_quota import assert_projection_readable, codex_quota_breakdown
34
36
  from _lib_accounts import UNATTRIBUTED
35
37
  from _lib_codex_pools import is_model_scoped_codex_quota
@@ -95,19 +97,35 @@ def _week_label(ref, tz) -> str:
95
97
  return ref.week_start.strftime("%b %d")
96
98
 
97
99
 
98
- def _count_blocks(conn: sqlite3.Connection, start_iso, end_iso) -> int:
100
+ def _account_predicate(account_key, column: str) -> "tuple[str, tuple]":
101
+ """``" AND <column> = ?"`` and its parameter for a scoped read, or nothing.
102
+
103
+ #836 spec 5.5: an explicit account is STRICT equality everywhere, so the
104
+ ``unattributed`` bucket never enters a real account's history and
105
+ ``account_key="unattributed"`` selects that bucket alone. ``None`` is the
106
+ merged read, unchanged.
107
+ """
108
+ if account_key is None:
109
+ return "", ()
110
+ return f" AND {column} = ?", (account_key,)
111
+
112
+
113
+ def _count_blocks(conn: sqlite3.Connection, start_iso, end_iso, *,
114
+ account_key=None) -> int:
99
115
  """Count ``five_hour_blocks`` whose interval intersects ``[start, end)``.
100
116
 
101
117
  Half-open intersection on epoch seconds via ``unixepoch()`` (mixed stored
102
118
  offsets). Returns 0 when the week lacks canonical boundaries.
119
+ ``account_key`` (#836) counts only that account's blocks.
103
120
  """
104
121
  if not start_iso or not end_iso:
105
122
  return 0
123
+ acct_sql, acct_params = _account_predicate(account_key, "account_key")
106
124
  row = conn.execute(
107
125
  "SELECT COUNT(*) FROM five_hour_blocks "
108
126
  "WHERE unixepoch(block_start_at) < unixepoch(?) "
109
- " AND unixepoch(five_hour_resets_at) > unixepoch(?)",
110
- (end_iso, start_iso),
127
+ " AND unixepoch(five_hour_resets_at) > unixepoch(?)" + acct_sql,
128
+ (end_iso, start_iso) + acct_params,
111
129
  ).fetchone()
112
130
  return int(row[0] or 0)
113
131
 
@@ -115,7 +133,8 @@ def _count_blocks(conn: sqlite3.Connection, start_iso, end_iso) -> int:
115
133
  # ── Claude week index (spec §1a) ───────────────────────────────────────
116
134
 
117
135
 
118
- def _navigable_claude_refs(conn: sqlite3.Connection) -> list:
136
+ def _navigable_claude_refs(conn: sqlite3.Connection, *, account_key=None,
137
+ as_of_utc=None) -> list:
119
138
  """The navigable Claude week refs, newest-first, per spec §1a.
120
139
 
121
140
  Navigable set = weeks with ≥1 ``weekly_usage_snapshots`` row ∪ weeks with
@@ -123,20 +142,30 @@ def _navigable_claude_refs(conn: sqlite3.Connection) -> list:
123
142
  reset-defined segment remains independently navigable; a defensive
124
143
  milestone-only week absent from ``get_recent_weeks`` is synthesized from
125
144
  its milestone rows' stored boundaries.
145
+
146
+ ``account_key`` (#836 spec 5.5) scopes every leg to one account's rows;
147
+ ``as_of_utc`` admits only reset events effective at or before it. Both
148
+ ``None`` is the merged, as-of-free enumeration, unchanged.
126
149
  """
127
150
  c = _cctally()
128
- refs = c.get_recent_weeks(conn, None) # unbounded, newest-first
151
+ # unbounded, newest-first
152
+ refs = (c.get_recent_weeks(conn, None) if account_key is None
153
+ else c.get_recent_weeks(conn, None, account_key=account_key))
129
154
 
155
+ acct_sql, acct_params = _account_predicate(account_key, "account_key")
156
+ acct_where = acct_sql.replace(" AND ", " WHERE ", 1)
130
157
  usage_keys = {
131
158
  r[0]
132
159
  for r in conn.execute(
133
160
  "SELECT DISTINCT week_start_date FROM weekly_usage_snapshots"
161
+ + acct_where, acct_params,
134
162
  ).fetchall()
135
163
  }
136
164
  milestone_keys = {
137
165
  r[0]
138
166
  for r in conn.execute(
139
167
  "SELECT DISTINCT week_start_date FROM percent_milestones"
168
+ + acct_where, acct_params,
140
169
  ).fetchall()
141
170
  }
142
171
  navigable = usage_keys | milestone_keys
@@ -144,10 +173,11 @@ def _navigable_claude_refs(conn: sqlite3.Connection) -> list:
144
173
  refs = [r for r in refs if r.key in navigable]
145
174
  present = {r.key for r in refs}
146
175
  for k in sorted(milestone_keys - present, reverse=True):
147
- ref = _synthesize_milestone_only_ref(conn, k)
176
+ ref = _synthesize_milestone_only_ref(conn, k, account_key=account_key)
148
177
  if ref is not None:
149
178
  refs.append(ref)
150
- return _derive_claude_reset_cycles(conn, refs)
179
+ return _derive_claude_reset_cycles(
180
+ conn, refs, account_key=account_key, as_of_utc=as_of_utc)
151
181
 
152
182
 
153
183
  def _parse_optional_iso(value):
@@ -159,17 +189,20 @@ def _parse_optional_iso(value):
159
189
  return None
160
190
 
161
191
 
162
- def _claude_storage_boundaries(conn: sqlite3.Connection, key: str):
163
- """Return the outer retained boundary for one storage date bucket."""
192
+ def _claude_storage_boundaries(conn: sqlite3.Connection, key: str, *,
193
+ account_key=None):
194
+ """Return the outer retained boundary for one storage date bucket
195
+ (``account_key``: that account's rows only, in both legs)."""
196
+ acct_sql, acct_params = _account_predicate(account_key, "account_key")
164
197
  rows = conn.execute(
165
198
  "SELECT week_start_at, week_end_at FROM weekly_usage_snapshots "
166
199
  "WHERE week_start_date=? AND week_start_at IS NOT NULL "
167
- "AND week_end_at IS NOT NULL "
200
+ "AND week_end_at IS NOT NULL" + acct_sql + " "
168
201
  "UNION ALL "
169
202
  "SELECT week_start_at, week_end_at FROM percent_milestones "
170
203
  "WHERE week_start_date=? AND week_start_at IS NOT NULL "
171
- "AND week_end_at IS NOT NULL",
172
- (key, key),
204
+ "AND week_end_at IS NOT NULL" + acct_sql,
205
+ (key,) + acct_params + (key,) + acct_params,
173
206
  ).fetchall()
174
207
  pairs = [
175
208
  (start, end)
@@ -183,51 +216,90 @@ def _claude_storage_boundaries(conn: sqlite3.Connection, key: str):
183
216
  return min(start for start, _end in pairs), max(end for _start, end in pairs)
184
217
 
185
218
 
186
- def _linked_reset_events(conn: sqlite3.Connection, key: str) -> list:
187
- """Reset rows linked to any retained boundary in a storage bucket."""
219
+ def _claude_reset_cuts_cycle(outer, effective) -> bool:
220
+ """Whether a linked reset's effective instant starts a new cycle inside a
221
+ storage bucket whose outer retained boundary is ``outer`` ``(start, end)``.
222
+
223
+ The ONE rule for where a Claude reset-defined cycle begins (#836 review
224
+ F7): the history index cuts its buckets with it, and the decorated card's
225
+ cycle scope (`_claude_current_cycle_scope`) starts its cycle with it, so
226
+ the card and its current index entry cannot disagree about the start. In
227
+ particular an early re-anchor stamped at the capture second can precede
228
+ the new window's nearest-hour start; while both windows share one storage
229
+ key the reset instant still starts the cycle.
230
+ """
231
+ return (outer is not None and effective is not None
232
+ and outer[0] < effective < outer[1])
233
+
234
+
235
+ def _linked_reset_events(conn: sqlite3.Connection, key: str, *,
236
+ account_key=None, as_of_utc=None) -> list:
237
+ """Reset rows linked to any retained boundary in a storage bucket.
238
+
239
+ #836 spec 5.5: the two ``EXISTS`` legs are one parenthesized group, so the
240
+ account and as-of predicates below bind the whole disjunction rather than
241
+ its second leg (``A OR B AND x`` is ``A OR (B AND x)``). ``account_key``
242
+ scopes the event and both legs' rows; ``as_of_utc`` admits only events
243
+ effective at or before it — the rule `_latest_reset_event_for_end` applies
244
+ for the card, so a reset that takes effect later cuts neither yet.
245
+ """
246
+ acct_s, acct_p = _account_predicate(account_key, "s.account_key")
247
+ acct_m, _ = _account_predicate(account_key, "m.account_key")
248
+ acct_e, _ = _account_predicate(account_key, "e.account_key")
249
+ as_of_sql = ""
250
+ as_of_params: tuple = ()
251
+ if as_of_utc is not None:
252
+ as_of_sql = (" AND unixepoch(e.effective_reset_at_utc) "
253
+ "<= unixepoch(?)")
254
+ as_of_params = (as_of_utc,)
188
255
  return conn.execute(
189
256
  "SELECT DISTINCT e.id, e.effective_reset_at_utc "
190
257
  "FROM week_reset_events e "
191
- "WHERE EXISTS ("
258
+ "WHERE (EXISTS ("
192
259
  " SELECT 1 FROM weekly_usage_snapshots s "
193
- " WHERE s.week_start_date=? AND ("
260
+ " WHERE s.week_start_date=?" + acct_s + " AND ("
194
261
  " unixepoch(s.week_end_at)=unixepoch(e.old_week_end_at) OR "
195
262
  " unixepoch(s.week_end_at)=unixepoch(e.new_week_end_at)"
196
263
  " )"
197
264
  ") OR EXISTS ("
198
265
  " SELECT 1 FROM percent_milestones m "
199
- " WHERE m.week_start_date=? AND ("
266
+ " WHERE m.week_start_date=?" + acct_m + " AND ("
200
267
  " unixepoch(m.week_end_at)=unixepoch(e.old_week_end_at) OR "
201
268
  " unixepoch(m.week_end_at)=unixepoch(e.new_week_end_at)"
202
269
  " )"
203
- ") "
270
+ "))" + acct_e + as_of_sql + " "
204
271
  "ORDER BY unixepoch(e.effective_reset_at_utc), e.id",
205
- (key, key),
272
+ (key,) + acct_p + (key,) + acct_p + acct_p + as_of_params,
206
273
  ).fetchall()
207
274
 
208
275
 
209
- def _derive_claude_reset_cycles(conn: sqlite3.Connection, refs: list) -> list:
276
+ def _derive_claude_reset_cycles(conn: sqlite3.Connection, refs: list, *,
277
+ account_key=None, as_of_utc=None) -> list:
210
278
  """Expand storage buckets into provider reset-defined cycles.
211
279
 
212
280
  ``get_recent_weeks`` can split an in-place credit, but an early re-anchor
213
281
  whose old and new boundaries both remain under one ``week_start_date``
214
282
  only exposes the pre-reset half. The retained snapshot boundary set plus
215
283
  ``week_reset_events`` is the authoritative reset ledger for both shapes.
284
+
285
+ ``account_key`` / ``as_of_utc`` (#836): that account's boundaries and
286
+ resets, cut only by events already effective at ``as_of_utc``.
216
287
  """
217
288
  out: list = []
218
289
  by_key: dict = {}
219
290
  for ref in refs:
220
291
  by_key.setdefault(ref.key, ref)
221
292
  for key, template in by_key.items():
222
- outer = _claude_storage_boundaries(conn, key)
293
+ outer = _claude_storage_boundaries(conn, key, account_key=account_key)
223
294
  if outer is None:
224
295
  out.append(template)
225
296
  continue
226
297
  start, end = outer
227
298
  cuts = []
228
- for event in _linked_reset_events(conn, key):
299
+ for event in _linked_reset_events(
300
+ conn, key, account_key=account_key, as_of_utc=as_of_utc):
229
301
  effective = _parse_optional_iso(event["effective_reset_at_utc"])
230
- if effective is not None and start < effective < end:
302
+ if _claude_reset_cuts_cycle(outer, effective):
231
303
  cuts.append(effective)
232
304
  boundaries = [start, *sorted(set(cuts)), end]
233
305
  cycles = []
@@ -242,18 +314,21 @@ def _derive_claude_reset_cycles(conn: sqlite3.Connection, refs: list) -> list:
242
314
  return _cctally()._apply_overlap_clamp_to_weekrefs(out)
243
315
 
244
316
 
245
- def _synthesize_milestone_only_ref(conn: sqlite3.Connection, key: str):
317
+ def _synthesize_milestone_only_ref(conn: sqlite3.Connection, key: str, *,
318
+ account_key=None):
246
319
  """Defensive ref for a milestone-only week (no usage snapshot).
247
320
 
248
321
  Boundaries fall back to the milestone rows' stored
249
322
  ``week_start_at``/``week_end_at``/``week_end_date`` (spec §1a). Returns
250
- ``None`` if a valid ref can't be built.
323
+ ``None`` if a valid ref can't be built. ``account_key`` (#836): from that
324
+ account's milestone rows only.
251
325
  """
326
+ acct_sql, acct_params = _account_predicate(account_key, "account_key")
252
327
  row = conn.execute(
253
328
  "SELECT week_end_date, week_start_at, week_end_at FROM percent_milestones "
254
- "WHERE week_start_date = ? "
329
+ "WHERE week_start_date = ?" + acct_sql + " "
255
330
  "ORDER BY (week_start_at IS NULL), captured_at_utc ASC LIMIT 1",
256
- (key,),
331
+ (key,) + acct_params,
257
332
  ).fetchone()
258
333
  if row is None:
259
334
  return None
@@ -289,58 +364,82 @@ def _claude_cycle_key(ref) -> str:
289
364
  )
290
365
 
291
366
 
292
- def _claude_cycle_rows(conn: sqlite3.Connection, ref) -> list:
293
- cohort_id = 0
294
- if ref.week_start_at:
295
- row = conn.execute(
296
- "SELECT e.id FROM week_reset_events e "
297
- "WHERE unixepoch(e.effective_reset_at_utc)=unixepoch(?) "
298
- "AND (EXISTS ("
299
- " SELECT 1 FROM weekly_usage_snapshots s "
300
- " WHERE s.week_start_date=? AND ("
301
- " unixepoch(s.week_end_at)=unixepoch(e.old_week_end_at) OR "
302
- " unixepoch(s.week_end_at)=unixepoch(e.new_week_end_at)"
303
- " )"
304
- ") OR EXISTS ("
305
- " SELECT 1 FROM percent_milestones m "
306
- " WHERE m.week_start_date=? AND ("
307
- " unixepoch(m.week_end_at)=unixepoch(e.old_week_end_at) OR "
308
- " unixepoch(m.week_end_at)=unixepoch(e.new_week_end_at)"
309
- " )"
310
- ")) ORDER BY e.id DESC LIMIT 1",
311
- (ref.week_start_at, ref.key, ref.key),
312
- ).fetchone()
313
- if row is not None:
314
- cohort_id = int(row[0])
367
+ def _claude_cycle_rows(conn: sqlite3.Connection, ref, *, account_key=None,
368
+ cohort_id=None) -> list:
369
+ """The cycle's weekly milestone cohort.
370
+
371
+ ``account_key`` (#836 spec 5.5) scopes the cohort event, both of its
372
+ supporting subqueries and the milestone read to one account. ``cohort_id``
373
+ names the cohort outright: a scoped index passes the card's active reset
374
+ for its CURRENT cycle, so the index, the detail and the card read one
375
+ segment (spec 5.2 step 2) rather than re-deriving it from the cut instant.
376
+ """
377
+ acct_s, acct_p = _account_predicate(account_key, "s.account_key")
378
+ acct_m, _ = _account_predicate(account_key, "m.account_key")
379
+ acct_e, _ = _account_predicate(account_key, "e.account_key")
380
+ acct_r, _ = _account_predicate(account_key, "account_key")
381
+ if cohort_id is None:
382
+ cohort_id = 0
383
+ if ref.week_start_at:
384
+ row = conn.execute(
385
+ "SELECT e.id FROM week_reset_events e "
386
+ "WHERE unixepoch(e.effective_reset_at_utc)=unixepoch(?)"
387
+ + acct_e + " "
388
+ "AND (EXISTS ("
389
+ " SELECT 1 FROM weekly_usage_snapshots s "
390
+ " WHERE s.week_start_date=?" + acct_s + " AND ("
391
+ " unixepoch(s.week_end_at)=unixepoch(e.old_week_end_at) OR "
392
+ " unixepoch(s.week_end_at)=unixepoch(e.new_week_end_at)"
393
+ " )"
394
+ ") OR EXISTS ("
395
+ " SELECT 1 FROM percent_milestones m "
396
+ " WHERE m.week_start_date=?" + acct_m + " AND ("
397
+ " unixepoch(m.week_end_at)=unixepoch(e.old_week_end_at) OR "
398
+ " unixepoch(m.week_end_at)=unixepoch(e.new_week_end_at)"
399
+ " )"
400
+ ")) ORDER BY e.id DESC LIMIT 1",
401
+ (ref.week_start_at,) + acct_p + (ref.key,) + acct_p
402
+ + (ref.key,) + acct_p,
403
+ ).fetchone()
404
+ if row is not None:
405
+ cohort_id = int(row[0])
315
406
  return conn.execute(
316
407
  "SELECT * FROM percent_milestones WHERE week_start_date=? "
317
- "AND reset_event_id=? "
408
+ "AND reset_event_id=?" + acct_r + " "
318
409
  "ORDER BY unixepoch(captured_at_utc) ASC, percent_threshold ASC",
319
- (ref.key, cohort_id),
410
+ (ref.key, cohort_id) + acct_p,
320
411
  ).fetchall()
321
412
 
322
413
 
323
- def _five_hour_detail_revision(conn, start_iso, end_iso) -> tuple[int, ...]:
414
+ def _five_hour_detail_revision(conn, start_iso, end_iso, *,
415
+ account_key=None) -> tuple[int, ...]:
324
416
  """Append-only 5h child revision for one cycle's intersecting blocks.
325
417
 
326
418
  The detail cache uses the index stamp. Weekly crossing counts do not move
327
419
  when another account records a 5h credit or crossing in an existing block.
328
420
  These child tables insert new ids for those events; count and max id also
329
421
  notice deletions and replacement rows without hashing an unbounded ledger.
422
+
423
+ #836 spec 5.4: ``TOTAL(m.usage_snapshot_id)`` moves when a snapshot DELETE
424
+ rewrites a retained crossing's join to the ``0`` sentinel, which changes
425
+ no count and no max id. ``account_key`` (spec 5.5) reads only that
426
+ account's blocks, so another account's changes never move a scoped stamp.
330
427
  """
331
428
  if not start_iso or not end_iso:
332
- return (0, 0, 0, 0, 0, 0)
429
+ return (0, 0, 0, 0, 0, 0, 0)
430
+ acct_sql, acct_params = _account_predicate(account_key, "b.account_key")
333
431
  interval = (
334
432
  "unixepoch(b.block_start_at) < unixepoch(?) "
335
- "AND unixepoch(b.five_hour_resets_at) > unixepoch(?)"
433
+ "AND unixepoch(b.five_hour_resets_at) > unixepoch(?)" + acct_sql
336
434
  )
337
- bounds = (end_iso, start_iso)
435
+ bounds = (end_iso, start_iso) + acct_params
338
436
  blocks = conn.execute(
339
437
  "SELECT COUNT(*), MAX(b.id) FROM five_hour_blocks b "
340
438
  f"WHERE {interval}", bounds,
341
439
  ).fetchone()
342
440
  milestones = conn.execute(
343
- "SELECT COUNT(*), MAX(m.id) FROM five_hour_blocks b "
441
+ "SELECT COUNT(*), MAX(m.id), TOTAL(m.usage_snapshot_id) "
442
+ "FROM five_hour_blocks b "
344
443
  "JOIN five_hour_milestones m ON m.block_id = b.id "
345
444
  f"WHERE {interval}", bounds,
346
445
  ).fetchone()
@@ -355,20 +454,87 @@ def _five_hour_detail_revision(conn, start_iso, end_iso) -> tuple[int, ...]:
355
454
  for value in row)
356
455
 
357
456
 
358
- def _index_entry(conn, ref, current_cycle_key, tz, *, decorated=False) -> dict:
359
- rows = _claude_cycle_rows(conn, ref)
457
+ def _unavailable_join_rows(conn, *, account_key=None) -> list:
458
+ """Every 5h milestone whose effective weekly join is unavailable, with
459
+ its block's bounds as SQLite epochs, ascending by id (#836 spec 5.4).
460
+
461
+ Read ONCE per index build and bucketed per cycle by
462
+ `_unavailable_join_component`. The bounds are wrapped in ``unixepoch()``,
463
+ which no index serves, so a per-cycle query scanned every milestone once
464
+ per index entry. The epochs are SQLite's own, so the bucketing compares
465
+ exactly what the per-cycle predicate compared; an unparseable bound is
466
+ NULL and never matches. ``account_key`` is the same strict scope
467
+ (`_account_predicate`).
468
+ """
469
+ from _cctally_five_hour import weekly_join_unavailable_sql
470
+ acct_sql, acct_params = _account_predicate(account_key, "b.account_key")
471
+ return [(int(r[0]), r[1], r[2]) for r in conn.execute(
472
+ "SELECT m.id, unixepoch(b.block_start_at), "
473
+ "unixepoch(b.five_hour_resets_at) FROM five_hour_blocks b "
474
+ "JOIN five_hour_milestones m ON m.block_id = b.id "
475
+ "LEFT JOIN weekly_usage_snapshots s ON s.id = m.usage_snapshot_id "
476
+ "WHERE " + weekly_join_unavailable_sql(conn, "s") + acct_sql
477
+ + " ORDER BY m.id",
478
+ acct_params,
479
+ ).fetchall()]
480
+
481
+
482
+ def _unavailable_join_component(conn, start_iso, end_iso,
483
+ unavailable_rows) -> tuple:
484
+ """The detail stamp's unavailable-join component (#836 spec 5.4).
485
+
486
+ A digest of the sorted ids of the cycle's 5h milestones whose effective
487
+ weekly join is unavailable — the joined snapshot absent, the ``0``
488
+ sentinel, or suppression-retired (`weekly_join_unavailable_sql`, the same
489
+ predicate as the dashboard's retired-join signature leg). Availability is
490
+ exactly what the fold's weekly rejection changes, and a set rather than a
491
+ count, so a restoration of one crossing beside the retirement of another
492
+ still moves the stamp. ``()`` when the set is empty, so a store with no
493
+ unavailable join keeps its stamp bytes.
494
+
495
+ ``unavailable_rows`` is the build's `_unavailable_join_rows`; a crossing
496
+ belongs to the cycle when its block intersects ``[start, end)`` — block
497
+ start before the cycle's end, block reset after the cycle's start — with
498
+ the cycle bounds converted by SQLite's ``unixepoch()`` as well.
499
+ """
500
+ if not start_iso or not end_iso or not unavailable_rows:
501
+ return ()
502
+ start_epoch, end_epoch = conn.execute(
503
+ "SELECT unixepoch(?), unixepoch(?)", (start_iso, end_iso)).fetchone()
504
+ if start_epoch is None or end_epoch is None:
505
+ return ()
506
+ ids = [mid for mid, block_start, block_resets in unavailable_rows
507
+ if block_start is not None and block_resets is not None
508
+ and block_start < end_epoch and block_resets > start_epoch]
509
+ if not ids:
510
+ return ()
511
+ return ("u" + _mh.compute_detail_stamp(*ids),)
512
+
513
+
514
+ def _index_entry(conn, ref, current_cycle_key, tz, *, unavailable_rows,
515
+ decorated=False, account_key=None, cohort_id=None) -> dict:
516
+ rows = _claude_cycle_rows(
517
+ conn, ref, account_key=account_key, cohort_id=cohort_id)
360
518
  milestone_count = len(rows)
361
519
  segment_count = 1 if rows else 0
362
520
  max_captured = max((r["captured_at_utc"] for r in rows), default=None)
363
521
  start_z = _to_iso_z(ref.week_start_at)
364
522
  end_z = _to_iso_z(ref.week_end_at)
365
- block_count = _count_blocks(conn, start_z, end_z)
523
+ block_count = _count_blocks(conn, start_z, end_z, account_key=account_key)
366
524
  key = _claude_cycle_key(ref)
367
525
  # R8: leave the <=1-real-account stamp byte-identical. On a decorated
368
526
  # provider the fetched block list contains multiple account-owned rows,
369
527
  # so its cache identity also needs their child-ledger revision.
370
528
  block_revision = (
371
- _five_hour_detail_revision(conn, start_z, end_z) if decorated else ())
529
+ _five_hour_detail_revision(conn, start_z, end_z, account_key=account_key)
530
+ if decorated else ())
531
+ # #836 spec 5.4/5.5: every store's stamp names which crossings have an
532
+ # unavailable weekly join (appended only when there is one), and a scoped
533
+ # stamp names its account, because one opaque cycle key is shared by every
534
+ # account whose subscription week has the same boundaries.
535
+ unavailable = _unavailable_join_component(
536
+ conn, start_z, end_z, unavailable_rows)
537
+ scope_part = () if account_key is None else ("a" + account_key,)
372
538
 
373
539
  # #750 S4 §5.3. A CURRENT single-segment cycle neither fetches its detail
374
540
  # nor uses one that arrives, so a gap in the LIVE cycle would stay
@@ -397,33 +563,120 @@ def _index_entry(conn, ref, current_cycle_key, tz, *, decorated=False) -> dict:
397
563
  "has_observation_gap": has_gap,
398
564
  "detail_stamp": _mh.compute_detail_stamp(
399
565
  key, milestone_count, block_count, segment_count, max_captured,
400
- *block_revision,
566
+ *block_revision, *unavailable, *scope_part,
401
567
  ),
402
568
  }
403
569
 
404
570
 
405
- def build_claude_week_index(conn: sqlite3.Connection) -> list:
571
+ def _same_instant(a, b) -> bool:
572
+ """Two stored boundary texts name the same instant (parsed, never by
573
+ spelling). Both absent is a match; one absent is not."""
574
+ if not a and not b:
575
+ return True
576
+ pa, pb = _parse_optional_iso(a), _parse_optional_iso(b)
577
+ return pa is not None and pb is not None and pa == pb
578
+
579
+
580
+ def _canonical_end(value):
581
+ try:
582
+ return _canonicalize_optional_iso(value, "claude.cycle.end")
583
+ except (TypeError, ValueError):
584
+ return None
585
+
586
+
587
+ def _scoped_current_ref(refs: list, cycle_scope):
588
+ """The ref in FULL interval agreement with the card's cycle (#836 spec
589
+ 5.5): the same storage key, the same effective start and the same
590
+ canonical end, parsed as instants; on the date-only leg, the ref with that
591
+ storage key and absent bounds. A start-only match is not enough and no ref
592
+ is current merely by position. ``None`` when nothing agrees."""
593
+ if cycle_scope is None:
594
+ return None
595
+ for ref in refs:
596
+ if ref.key != cycle_scope.week_start_date:
597
+ continue
598
+ if cycle_scope.week_start_at is None:
599
+ if not ref.week_start_at and not ref.week_end_at:
600
+ return ref
601
+ continue
602
+ if (_same_instant(ref.week_start_at, cycle_scope.effective_start_at)
603
+ and _same_instant(_canonical_end(ref.week_end_at),
604
+ cycle_scope.week_end_at)):
605
+ return ref
606
+ return None
607
+
608
+
609
+ def _scoped_refs(conn, account_key, cycle_scope, now_utc=None):
610
+ """The scoped enumeration and its current ref (#836 spec 5.5).
611
+
612
+ Derived at the scope's as-of instant, so a retained reset that takes
613
+ effect later cuts neither the card nor the index yet. Without a scope the
614
+ as-of is ``now_utc`` (the build's own instant), and no ref is current.
615
+ """
616
+ if cycle_scope is not None:
617
+ as_of = cycle_scope.as_of_utc
618
+ elif now_utc is not None:
619
+ as_of = _iso_z(now_utc)
620
+ else:
621
+ as_of = None
622
+ refs = _navigable_claude_refs(
623
+ conn, account_key=account_key, as_of_utc=as_of)
624
+ scope = (cycle_scope if cycle_scope is not None
625
+ and cycle_scope.account_key == account_key else None)
626
+ return refs, _scoped_current_ref(refs, scope), scope
627
+
628
+
629
+ def build_claude_week_index(conn: sqlite3.Connection, *, account_key=None,
630
+ cycle_scope=None, now_utc=None) -> list:
406
631
  """Newest-first navigable Claude week index (spec §1a, §3).
407
632
 
408
633
  One entry per effective reset-defined cycle. Multiple cycles may share one
409
634
  storage ``week_start_date`` but always have distinct opaque keys.
635
+
636
+ ``account_key`` (#836 spec 5.5, U1) builds ONE account's history: its own
637
+ cycles, cohorts, block counts and stamps, derived at ``cycle_scope``'s
638
+ as-of; the current entry is the one in full interval agreement with that
639
+ scope (the card's cycle), and its cohort is the scope's active reset.
640
+ ``None`` keeps the merged contract byte-for-byte apart from the
641
+ unavailable-join stamp component.
410
642
  """
411
- refs = _navigable_claude_refs(conn)
412
- current_key = _current_claude_week_key(conn)
413
- current_refs = [r for r in refs if r.key == current_key]
414
- current_ref = max(
415
- current_refs,
416
- key=lambda r: r.week_start_at or "",
417
- default=None,
418
- )
419
- current_cycle_key = _claude_cycle_key(current_ref) if current_ref else None
420
- tz = _resolve_display_tz()
421
643
  import _cctally_account
422
644
  decorated = _cctally_account.provider_is_decorated(conn, "claude")
423
- entries = [
424
- _index_entry(conn, ref, current_cycle_key, tz, decorated=decorated)
425
- for ref in refs
426
- ]
645
+ tz = _resolve_display_tz()
646
+ if account_key is None:
647
+ refs = _navigable_claude_refs(conn)
648
+ current_key = _current_claude_week_key(conn)
649
+ current_refs = [r for r in refs if r.key == current_key]
650
+ current_ref = max(
651
+ current_refs,
652
+ key=lambda r: r.week_start_at or "",
653
+ default=None,
654
+ )
655
+ current_cycle_key = (
656
+ _claude_cycle_key(current_ref) if current_ref else None)
657
+ unavailable_rows = _unavailable_join_rows(conn)
658
+ entries = [
659
+ _index_entry(conn, ref, current_cycle_key, tz,
660
+ unavailable_rows=unavailable_rows, decorated=decorated)
661
+ for ref in refs
662
+ ]
663
+ else:
664
+ refs, current_ref, scope = _scoped_refs(
665
+ conn, account_key, cycle_scope, now_utc)
666
+ current_cycle_key = (
667
+ _claude_cycle_key(current_ref) if current_ref else None)
668
+ unavailable_rows = _unavailable_join_rows(
669
+ conn, account_key=account_key)
670
+ entries = [
671
+ _index_entry(
672
+ conn, ref, current_cycle_key, tz,
673
+ unavailable_rows=unavailable_rows, decorated=decorated,
674
+ account_key=account_key,
675
+ cohort_id=(scope.reset_event_id
676
+ if ref is current_ref else None),
677
+ )
678
+ for ref in refs
679
+ ]
427
680
  entries.sort(key=lambda e: e["start_at_utc"] or "", reverse=True)
428
681
  return entries
429
682
 
@@ -512,12 +765,15 @@ def _load_block_credits(
512
765
  ]
513
766
 
514
767
 
515
- def _build_blocks(conn: sqlite3.Connection, start_iso, end_iso) -> list:
768
+ def _build_blocks(conn: sqlite3.Connection, start_iso, end_iso, *,
769
+ account_key=None) -> list:
516
770
  """``five_hour_blocks`` intersecting ``[start, end)``, ascending by start;
517
771
  each carries its 5h milestone stream + credit rows (spec §1b). A block
518
- straddling a week boundary appears in every week it intersects."""
772
+ straddling a week boundary appears in every week it intersects.
773
+ ``account_key`` (#836 spec 5.5): only that account's blocks."""
519
774
  if not start_iso or not end_iso:
520
775
  return []
776
+ acct_sql, acct_params = _account_predicate(account_key, "account_key")
521
777
  c = _cctally()
522
778
  import _cctally_account
523
779
  decorated = _cctally_account.provider_is_decorated(conn, "claude")
@@ -533,9 +789,9 @@ def _build_blocks(conn: sqlite3.Connection, start_iso, end_iso) -> list:
533
789
  " crossed_seven_day_reset, is_closed "
534
790
  "FROM five_hour_blocks "
535
791
  "WHERE unixepoch(block_start_at) < unixepoch(?) "
536
- " AND unixepoch(five_hour_resets_at) > unixepoch(?) "
792
+ " AND unixepoch(five_hour_resets_at) > unixepoch(?)" + acct_sql + " "
537
793
  "ORDER BY unixepoch(block_start_at) ASC, five_hour_window_key ASC, id ASC",
538
- (end_iso, start_iso),
794
+ (end_iso, start_iso) + acct_params,
539
795
  ).fetchall()
540
796
  out: list = []
541
797
  for b in rows:
@@ -564,14 +820,45 @@ def _build_blocks(conn: sqlite3.Connection, start_iso, end_iso) -> list:
564
820
  return out
565
821
 
566
822
 
567
- def build_claude_week_detail(conn: sqlite3.Connection, key: str) -> "dict | None":
568
- """Complete payload for one reset-defined Claude cycle."""
569
- refs = _navigable_claude_refs(conn)
570
- ref = next((candidate for candidate in refs if _claude_cycle_key(candidate) == key), None)
571
- if ref is None:
572
- return None
573
- entry = next(e for e in build_claude_week_index(conn) if e["key"] == key)
574
- rows = _claude_cycle_rows(conn, ref)
823
+ def build_claude_week_detail(conn: sqlite3.Connection, key: str, *,
824
+ account_key=None, now_utc=None) -> "dict | None":
825
+ """Complete payload for one reset-defined Claude cycle.
826
+
827
+ ``account_key`` (#836 spec 5.5/5.6, U1) serves that account's cycle from
828
+ its own scoped index: the account's current cycle scope is resolved at
829
+ ``now_utc`` (the route's as-of), exactly as the dashboard resolves it for
830
+ the card, so the current key's bounds here equal the card's. A key absent
831
+ from the account's scoped index is ``None``; it is never retried merged.
832
+ """
833
+ if account_key is None:
834
+ refs = _navigable_claude_refs(conn)
835
+ ref = next((candidate for candidate in refs
836
+ if _claude_cycle_key(candidate) == key), None)
837
+ if ref is None:
838
+ return None
839
+ entry = next(e for e in build_claude_week_index(conn) if e["key"] == key)
840
+ cohort_id = None
841
+ else:
842
+ if now_utc is None:
843
+ now_utc = dt.datetime.now(UTC)
844
+ cycle_scope = _cctally()._claude_current_cycle_scope(
845
+ conn, now_utc, account_key=account_key)
846
+ refs, current_ref, scope = _scoped_refs(
847
+ conn, account_key, cycle_scope, now_utc)
848
+ ref = next((candidate for candidate in refs
849
+ if _claude_cycle_key(candidate) == key), None)
850
+ if ref is None:
851
+ return None
852
+ entry = next(
853
+ (e for e in build_claude_week_index(
854
+ conn, account_key=account_key, cycle_scope=scope,
855
+ now_utc=now_utc)
856
+ if e["key"] == key), None)
857
+ if entry is None:
858
+ return None
859
+ cohort_id = scope.reset_event_id if ref is current_ref else None
860
+ rows = _claude_cycle_rows(
861
+ conn, ref, account_key=account_key, cohort_id=cohort_id)
575
862
  # Same absent-boundary case `_claude_cycle_key` handles, and this call runs
576
863
  # unconditionally, ahead of the `if rows` guard below.
577
864
  segment_key = dashboard_resource_key(
@@ -589,7 +876,8 @@ def build_claude_week_detail(conn: sqlite3.Connection, key: str) -> "dict | None
589
876
  ]}]
590
877
  if rows else [])
591
878
  gap_runs = _shape_observation_gap_runs(disclosure, rows) if rows else []
592
- blocks = _build_blocks(conn, entry["start_at_utc"], entry["end_at_utc"])
879
+ blocks = _build_blocks(conn, entry["start_at_utc"], entry["end_at_utc"],
880
+ account_key=account_key)
593
881
 
594
882
  return {
595
883
  "source": "claude",