cctally 1.116.0 → 1.117.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 (72) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +224 -7
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +34 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +389 -18
  15. package/bin/_cctally_forecast.py +30 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_oauth_samples.py +185 -7
  18. package/bin/_cctally_parser.py +38 -0
  19. package/bin/_cctally_project.py +34 -8
  20. package/bin/_cctally_quarantine.py +821 -343
  21. package/bin/_cctally_quota.py +69 -4
  22. package/bin/_cctally_quota_calibration.py +677 -3
  23. package/bin/_cctally_quota_model.py +927 -127
  24. package/bin/_cctally_record.py +922 -445
  25. package/bin/_cctally_rederive.py +119 -15
  26. package/bin/_cctally_statusline.py +1048 -176
  27. package/bin/_cctally_store.py +544 -110
  28. package/bin/_cctally_tui.py +93 -9
  29. package/bin/_cctally_update.py +77 -14
  30. package/bin/_lib_account_protocol.py +227 -19
  31. package/bin/_lib_claude_attribution.py +421 -0
  32. package/bin/_lib_clone_copy.py +30 -0
  33. package/bin/_lib_codex_conversation_query.py +45 -9
  34. package/bin/_lib_codex_conversation_watch.py +72 -13
  35. package/bin/_lib_codex_rollout_container.py +234 -19
  36. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  37. package/bin/_lib_credit.py +22 -13
  38. package/bin/_lib_dashboard_sources.py +163 -10
  39. package/bin/_lib_detector_access_registry.py +1335 -166
  40. package/bin/_lib_doctor.py +276 -92
  41. package/bin/_lib_ingest_frontier.py +269 -36
  42. package/bin/_lib_journal.py +273 -22
  43. package/bin/_lib_jsonl.py +12 -0
  44. package/bin/_lib_meter_rate_change.py +72 -0
  45. package/bin/_lib_outcome_quarantine.py +917 -554
  46. package/bin/_lib_provider_activity.py +24 -6
  47. package/bin/_lib_quota_calibration.py +57 -3
  48. package/bin/_lib_quota_copy.py +7 -2
  49. package/bin/_lib_quota_lineage.py +613 -0
  50. package/bin/_lib_quota_model.py +435 -22
  51. package/bin/_lib_rate_change_delivery.py +16 -0
  52. package/bin/_lib_reclaim_planner.py +6 -4
  53. package/bin/_lib_rederive.py +2046 -21
  54. package/bin/_lib_selector_state.py +131 -3
  55. package/bin/_lib_stale_proof.py +113 -4
  56. package/bin/_lib_statusline.py +72 -3
  57. package/bin/_lib_statusline_candidates.py +246 -42
  58. package/bin/_lib_write_io.py +418 -5
  59. package/bin/cctally +24 -11
  60. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-Dcfn8T6f.js} +1 -1
  61. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-BqmXbnhn.js} +1 -1
  62. package/dashboard/static/assets/{ModalRoot-HlxoagIa.js → ModalRoot-Ck3WJK2V.js} +1 -1
  63. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-B-HZkDnT.js} +1 -1
  64. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-QhJkHB7i.js} +1 -1
  65. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CkXEIQcK.js} +1 -1
  66. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  67. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-HKS1s56o.js} +3 -3
  68. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-BNGaNPCp.js} +1 -1
  69. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  70. package/dashboard/static/dashboard.html +3 -3
  71. package/package.json +5 -1
  72. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -0,0 +1,997 @@
1
+ """Write-time Claude cost attribution glue (#944).
2
+
3
+ The pure rule and identity encoders live in ``_lib_claude_attribution``; this
4
+ module owns their cache.db and journal side (spec
5
+ ``docs/superpowers/specs/2026-10-29-944-claude-write-time-cost-attribution-design.md``
6
+ sections 5.1 and 5.4-5.6):
7
+
8
+ * the two derived, journal-backed tables -- ``claude_account_timeline`` (one
9
+ row per admissible Claude ``record-usage`` observation) and
10
+ ``claude_cost_decisions`` (one row per decision key a
11
+ ``claude_cost_account`` op names) -- and their replay from the journal;
12
+ * timeline neighbour seeks and decision lookup;
13
+ * the fail-closed append of decision batches (journal first, then the
14
+ decision rows in the caller's transaction);
15
+ * alignment of an unlinked accounting row to the decision its identity
16
+ already holds, and the preserve-before-wipe export of unlinked rows.
17
+
18
+ Transactions: every function here runs its statements on the caller's
19
+ connection and never commits, except ``record_deferral``, which is its own
20
+ small transaction. Locks: the caller holds the Claude cache writer flock; the
21
+ only lock taken here is the journal's leaf ``journal.lock`` inside
22
+ ``append_records``, released before it returns (the lock-order law).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import dataclasses
27
+ import datetime as dt
28
+ import json
29
+ import sqlite3
30
+ from typing import Sequence
31
+
32
+ import _cctally_core
33
+ import _lib_account_binding
34
+ import _lib_account_protocol
35
+ import _lib_claude_attribution as K
36
+ import _lib_journal
37
+
38
+
39
+ CURSOR_KEY = "claude_attribution_rehydrated_hw"
40
+ CONFLICTS_KEY = "claude_cost_decision_conflicts"
41
+ ALIGNED_KEY = "claude_cost_aligned_rows"
42
+ PRESERVATION_KEY = "claude_cost_preservation"
43
+ DEFERRAL_KEY = "claude_attribution_last_deferral"
44
+ #: The deferral reason a sync reports when the journal cannot be read or
45
+ #: written (the reason the Codex attribution replay already uses).
46
+ DEFERRAL_REASON = "attribution_rehydration"
47
+ MUTATION_SEQ_KEY = "session_entries_mutation_seq"
48
+
49
+ #: Decision batches stay well under the journal's 64 KiB line limit.
50
+ _BATCH_LINE_BUDGET = 48 * 1024
51
+ #: Rows one preserve chunk scans before it appends.
52
+ _PRESERVE_CHUNK = 5_000
53
+ _MAX_PRECISION_US = 1_000_000
54
+
55
+ _OBS_MARKERS = (b'"account_binding"', b'"record-usage"')
56
+ _DECISION_MARKER = b'"' + _lib_journal.CLAUDE_COST_ACCOUNT_KIND.encode() + b'"'
57
+
58
+ _PROJECTION_COLUMNS = ", ".join(K.PROJECTION_FIELDS)
59
+
60
+
61
+ class AttributionJournalUnavailable(Exception):
62
+ """The journal could not be read or written, so no attribution decision
63
+ may be made durable. ``contended`` distinguishes lock contention (a
64
+ silent deferral) from a genuine failure."""
65
+
66
+ def __init__(self, message: str, *, contended: bool = False):
67
+ super().__init__(message)
68
+ self.contended = contended
69
+
70
+
71
+ @dataclasses.dataclass(frozen=True)
72
+ class RecordIdentity:
73
+ """The durable identity a cost record or cached row is decided under.
74
+
75
+ ``k1`` names a logical ``(msg_id, req_id)``; ``p1`` a record lacking an id
76
+ by its exact raw bytes at ``(loc, offset)`` (``projection`` additionally
77
+ lets lookup fall back to a legacy decision at that location); ``l1`` a
78
+ cached row lacking an id by its legacy projection."""
79
+
80
+ form: str
81
+ key: str
82
+ msg_id: "str | None" = None
83
+ req_id: "str | None" = None
84
+ loc: "str | None" = None
85
+ offset: "int | None" = None
86
+ digest: "str | None" = None
87
+ projection: "list | None" = None
88
+
89
+
90
+ @dataclasses.dataclass(frozen=True)
91
+ class StoredDecision:
92
+ key: str
93
+ account_key: "str | None"
94
+ basis: str
95
+ reason: "str | None"
96
+ decided_at_utc: str
97
+ journal_pos: tuple
98
+
99
+
100
+ @dataclasses.dataclass(frozen=True)
101
+ class PreserveResult:
102
+ saved: int
103
+ aligned: int
104
+ exported: int
105
+ last_id: int
106
+ remaining: bool
107
+
108
+
109
+ # --------------------------------------------------------------------------
110
+ # identities
111
+ # --------------------------------------------------------------------------
112
+
113
+ def keyed_identity(msg_id: str, req_id: str) -> RecordIdentity:
114
+ return RecordIdentity(form=K.FORM_KEYED, key=K.keyed_key(msg_id, req_id),
115
+ msg_id=msg_id, req_id=req_id)
116
+
117
+
118
+ def record_identity(*, msg_id, req_id, source_path: str, byte_offset: int,
119
+ record_bytes: bytes, projection: list) -> RecordIdentity:
120
+ """A cost record's identity: keyed when both ids are valid record ids
121
+ (``K.keyed_ids``), otherwise physical (exact raw bytes) with its
122
+ projection for the legacy fallback. ``record_bytes`` is read only for a
123
+ physical identity (the caller may pass None for a keyed record)."""
124
+ ids = K.keyed_ids(msg_id, req_id)
125
+ if ids is not None:
126
+ return keyed_identity(*ids)
127
+ loc = K.loc_for(source_path)
128
+ digest = K.record_sha256(record_bytes)
129
+ return RecordIdentity(
130
+ form=K.FORM_PHYSICAL,
131
+ key=K.physical_key_from_digest(loc, byte_offset, digest),
132
+ loc=loc, offset=int(byte_offset), digest=digest,
133
+ projection=list(projection))
134
+
135
+
136
+ def row_identity(row) -> RecordIdentity:
137
+ """A cached accounting row's identity: keyed (both ids valid record ids,
138
+ ``K.keyed_ids``), or the legacy projection of the row itself (an export
139
+ never reads transcript bytes). A row stored before #944 F1 with an empty
140
+ or non-string id therefore exports in legacy form, which is where the
141
+ re-read record's physical lookup falls back to."""
142
+ ids = K.keyed_ids(row["msg_id"], row["req_id"])
143
+ if ids is not None:
144
+ return keyed_identity(*ids)
145
+ projection = K.legacy_projection(row)
146
+ loc = K.loc_for(row["source_path"])
147
+ digest = K.projection_sha256(projection)
148
+ return RecordIdentity(
149
+ form=K.FORM_LEGACY,
150
+ key=K.legacy_key_from_digest(loc, row["line_offset"], digest),
151
+ loc=loc, offset=int(row["line_offset"]), digest=digest,
152
+ projection=projection)
153
+
154
+
155
+ # --------------------------------------------------------------------------
156
+ # schema and cache_meta
157
+ # --------------------------------------------------------------------------
158
+
159
+ def ensure_claude_attribution_schema(conn) -> None:
160
+ import _cctally_db
161
+ _cctally_db._apply_claude_attribution_schema(conn)
162
+
163
+
164
+ def schema_present(conn) -> bool:
165
+ import _cctally_db
166
+ return _cctally_db.claude_attribution_schema_present(conn)
167
+
168
+
169
+ def _meta_json(conn, key):
170
+ try:
171
+ row = conn.execute(
172
+ "SELECT value FROM cache_meta WHERE key=?", (key,)).fetchone()
173
+ except sqlite3.OperationalError:
174
+ return None
175
+ if row is None or row[0] is None:
176
+ return None
177
+ try:
178
+ return json.loads(row[0])
179
+ except (TypeError, ValueError):
180
+ return None
181
+
182
+
183
+ def _meta_set(conn, key, value) -> None:
184
+ conn.execute(
185
+ "INSERT INTO cache_meta(key, value) VALUES(?, ?) "
186
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
187
+ (key, json.dumps(value, sort_keys=True, separators=(",", ":"))))
188
+
189
+
190
+ def _now_iso() -> str:
191
+ return _cctally_core.now_utc_iso()
192
+
193
+
194
+ def _bump_counter(conn, key, by: int, at) -> None:
195
+ current = _meta_json(conn, key)
196
+ count = 0
197
+ if isinstance(current, dict) and type(current.get("count")) is int:
198
+ count = current["count"]
199
+ _meta_set(conn, key, {"count": count + int(by), "last_at": at})
200
+
201
+
202
+ def record_deferral(conn, reason: str) -> None:
203
+ """Record ``claude_attribution_last_deferral`` in its own small
204
+ transaction. Call with no transaction of the caller open."""
205
+ _meta_set(conn, DEFERRAL_KEY, {"reason": reason, "at": _now_iso()})
206
+ conn.commit()
207
+
208
+
209
+ def clear_deferral(conn) -> None:
210
+ """Drop a recorded deferral, in the caller's transaction (a sync that
211
+ appended successfully)."""
212
+ conn.execute("DELETE FROM cache_meta WHERE key=?", (DEFERRAL_KEY,))
213
+
214
+
215
+ def deferral_recorded(conn) -> bool:
216
+ return _meta_json(conn, DEFERRAL_KEY) is not None
217
+
218
+
219
+ def last_deferral(conn):
220
+ """``claude_attribution_last_deferral`` as recorded, or None."""
221
+ doc = _meta_json(conn, DEFERRAL_KEY)
222
+ return doc if isinstance(doc, dict) else None
223
+
224
+
225
+ def read_preservation(conn):
226
+ """Migration 050's ``claude_cost_preservation`` progress document
227
+ (``afterId, saved, total, lastError, lastErrorAt, lastChunkAt``), or None
228
+ before its first invocation."""
229
+ doc = _meta_json(conn, PRESERVATION_KEY)
230
+ return doc if isinstance(doc, dict) else None
231
+
232
+
233
+ def write_preservation(conn, doc) -> None:
234
+ """Store the progress document in the caller's transaction."""
235
+ _meta_set(conn, PRESERVATION_KEY, {
236
+ key: doc.get(key) for key in (
237
+ "afterId", "saved", "total", "lastError", "lastErrorAt",
238
+ "lastChunkAt")})
239
+
240
+
241
+ def aligned_rows(conn) -> int:
242
+ doc = _meta_json(conn, ALIGNED_KEY)
243
+ if isinstance(doc, dict) and type(doc.get("count")) is int:
244
+ return doc["count"]
245
+ return 0
246
+
247
+
248
+ # --------------------------------------------------------------------------
249
+ # replay
250
+ # --------------------------------------------------------------------------
251
+
252
+ def _admission_fingerprint(boundary):
253
+ if boundary is None:
254
+ return None
255
+ revision = boundary.revision
256
+ return [boundary.segment, int(boundary.end_offset),
257
+ None if revision is None else revision.segment,
258
+ None if revision is None else int(revision.end_offset)]
259
+
260
+
261
+ def _stored_cursor(conn, fingerprint):
262
+ """The replay cursor, or ``None`` when it is absent, malformed, or was
263
+ written under another rule version or admission boundary (each of which
264
+ invalidates the derived tables)."""
265
+ doc = _meta_json(conn, CURSOR_KEY)
266
+ if not isinstance(doc, dict):
267
+ return None
268
+ if doc.get("rule") != K.RULE_VERSION or doc.get(
269
+ "admission") != fingerprint:
270
+ return None
271
+ segment, offset = doc.get("segment"), doc.get("offset")
272
+ if not isinstance(segment, str) or not segment or type(
273
+ offset) is not int or offset < 0:
274
+ return None
275
+ return (segment, offset)
276
+
277
+
278
+ def _store_cursor(conn, hw, fingerprint) -> None:
279
+ _meta_set(conn, CURSOR_KEY, {
280
+ "segment": str(hw[0]), "offset": int(hw[1]),
281
+ "rule": K.RULE_VERSION, "admission": fingerprint})
282
+
283
+
284
+ def _clear_derived(conn) -> None:
285
+ conn.execute("DELETE FROM claude_cost_decisions")
286
+ conn.execute("DELETE FROM claude_account_timeline")
287
+
288
+
289
+ def _is_claude_usage_obs(rec) -> bool:
290
+ return (rec.get("t") == "obs" and rec.get("provider") == "claude"
291
+ and rec.get("src") == "record-usage")
292
+
293
+
294
+ def _observation_row(rec):
295
+ """``(captured_at_utc, precision_us, account_key)`` of an admissible
296
+ timeline observation (spec 5.1), or ``None``."""
297
+ payload = rec.get("payload")
298
+ if not isinstance(payload, dict):
299
+ return None
300
+ try:
301
+ lo, hi = K.capture_interval(payload.get("captured_at"))
302
+ binding = _lib_account_binding.load(payload.get("account_binding"))
303
+ except (ValueError, TypeError, OverflowError, KeyError):
304
+ return None
305
+ account = K.normalize_binding_account(binding.account_key)
306
+ top = rec.get("account")
307
+ if top != account:
308
+ # An omitted top-level stamp is NULL; anything else must equal the
309
+ # binding's normalized account.
310
+ return None
311
+ return (K.iso_from_us(lo), hi - lo, account)
312
+
313
+
314
+ def _apply_observation(conn, rec, segment, offset) -> None:
315
+ row = _observation_row(rec)
316
+ if row is None:
317
+ return
318
+ captured, precision, account = row
319
+ conn.execute(
320
+ "INSERT INTO claude_account_timeline (captured_at_utc, precision_us,"
321
+ " account_key, journal_segment, journal_offset) SELECT ?,?,?,?,? "
322
+ "WHERE NOT EXISTS (SELECT 1 FROM claude_account_timeline WHERE "
323
+ "captured_at_utc=? AND journal_segment=? AND journal_offset=?)",
324
+ (captured, precision, account, segment, int(offset),
325
+ captured, segment, int(offset)))
326
+
327
+
328
+ def _item_values(item, payload):
329
+ """``(decision_key, form, msg_id, req_id, loc, byte_offset, digest,
330
+ record_ts_utc, projection_json)`` of one validated item."""
331
+ form = item[0]
332
+ if form == K.FORM_KEYED:
333
+ ts = item[3] if len(item) == 4 else None
334
+ return (K.keyed_key(item[1], item[2]), form, item[1], item[2],
335
+ None, None, None, ts, None)
336
+ if form == K.FORM_PHYSICAL:
337
+ return (K.physical_key_from_digest(item[1], item[2], item[3]), form,
338
+ None, None, item[1], item[2], item[3], item[4], None)
339
+ return (K.legacy_key_from_digest(item[1], item[2], item[3]), form,
340
+ None, None, item[1], item[2], item[3], None,
341
+ K.projection_json(item[4]))
342
+
343
+
344
+ _DECISION_INSERT = (
345
+ "INSERT INTO claude_cost_decisions (decision_key, form, msg_id, req_id,"
346
+ " loc, byte_offset, digest, account_key, basis, reason, rule,"
347
+ " decided_at_utc, record_ts_utc, projection_json, journal_segment,"
348
+ " journal_offset) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
349
+ "ON CONFLICT(decision_key) DO NOTHING")
350
+
351
+
352
+ def _insert_decision_items(conn, payload, segment, offset) -> int:
353
+ """Insert every item of one validated op first-wins; return how many
354
+ contradicted an existing decision for the same key (conflicts)."""
355
+ account = payload.get("account_key")
356
+ conflicts = 0
357
+ for item in payload["items"]:
358
+ (key, form, msg_id, req_id, loc, byte_offset, digest, ts,
359
+ projection) = _item_values(item, payload)
360
+ before = conn.total_changes
361
+ conn.execute(_DECISION_INSERT, (
362
+ key, form, msg_id, req_id, loc, byte_offset, digest, account,
363
+ payload["basis"], payload.get("reason"), payload["rule"],
364
+ payload["decided_at"], ts, projection, segment, int(offset)))
365
+ if conn.total_changes > before:
366
+ continue
367
+ prior = conn.execute(
368
+ "SELECT account_key FROM claude_cost_decisions "
369
+ "WHERE decision_key=?", (key,)).fetchone()
370
+ if prior is not None and prior[0] != account:
371
+ conflicts += 1
372
+ return conflicts
373
+
374
+
375
+ def _decision_payload(rec):
376
+ if rec.get("t") != "op":
377
+ return None
378
+ payload = rec.get("payload")
379
+ if not isinstance(payload, dict) or payload.get(
380
+ "kind") != _lib_journal.CLAUDE_COST_ACCOUNT_KIND:
381
+ return None
382
+ try:
383
+ _lib_journal.validate_claude_cost_account(payload)
384
+ except ValueError:
385
+ return None
386
+ return payload
387
+
388
+
389
+ def rehydrate_claude_attribution(conn, *, authoritative: bool
390
+ ) -> "tuple | None":
391
+ """Replay the journal suffix past the Claude attribution cursor into
392
+ ``claude_cost_decisions`` and ``claude_account_timeline``, in ONE
393
+ traversal. Returns the safe high-water the replay consumed --
394
+ ``(segment, offset)``, or the current segment at offset 0 when no journal
395
+ exists -- which every decision the caller then appends records as its
396
+ ``journal_hw`` (spec 5.4: the safe high-water the rule ran against, not
397
+ one sampled later, past another writer's appends). Returns None when the
398
+ journal could not be read (the caller rolls back and defers).
399
+
400
+ ``authoritative`` clears both tables and replays from the journal start
401
+ (an explicit rebuild, the truncation escalation, a recreated cache). An
402
+ absent or invalid cursor, or one written under another rule version or
403
+ admission boundary, forces the same. Admission (``classify_admission``)
404
+ runs before interpretation. A malformed decision payload is skipped, never
405
+ applied. Conflicts -- a decision contradicting the first one recorded for
406
+ its key -- are a pure function of the journal: an authoritative replay
407
+ recounts them from zero, an incremental one adds to the count.
408
+
409
+ Runs on the caller's transaction and never commits; the cursor and the
410
+ rows it describes therefore commit together."""
411
+ import _cctally_journal as jr
412
+ try:
413
+ hw = jr.journal_safe_high_water()
414
+ boundary = None if hw is None else jr.find_account_bound_cutover()
415
+ except (OSError, jr.JournalError):
416
+ return None
417
+ fingerprint = _admission_fingerprint(boundary)
418
+ cursor = _stored_cursor(conn, fingerprint)
419
+ if cursor is None:
420
+ authoritative = True
421
+ if hw is None:
422
+ if authoritative:
423
+ # No journal: the truth is that nothing has been decided, and a
424
+ # cursor must never outlive the rows it describes.
425
+ _clear_derived(conn)
426
+ conn.execute("DELETE FROM cache_meta WHERE key=?", (CURSOR_KEY,))
427
+ conn.execute("DELETE FROM cache_meta WHERE key=?",
428
+ (CONFLICTS_KEY,))
429
+ return _empty_journal_high_water()
430
+ if authoritative:
431
+ _clear_derived(conn)
432
+ cursor = None
433
+ segments = jr.list_segments()
434
+ conflicts = 0
435
+ last_conflict_at = None
436
+ try:
437
+ for segment, offset, raw in jr.iter_range(cursor, hw, segments):
438
+ decision_hit = _DECISION_MARKER in raw
439
+ obs_hit = not decision_hit and all(
440
+ marker in raw for marker in _OBS_MARKERS)
441
+ if not (decision_hit or obs_hit):
442
+ continue
443
+ rec = _lib_journal.decode_line(raw)
444
+ if rec is None:
445
+ continue
446
+ if boundary is not None and not (
447
+ _lib_account_protocol.classify_admission(
448
+ rec, _lib_account_protocol.Coordinate(
449
+ segment, int(offset)), boundary).admitted):
450
+ continue
451
+ if decision_hit:
452
+ payload = _decision_payload(rec)
453
+ if payload is not None:
454
+ found = _insert_decision_items(
455
+ conn, payload, segment, offset)
456
+ if found:
457
+ conflicts += found
458
+ last_conflict_at = payload["decided_at"]
459
+ continue
460
+ if _is_claude_usage_obs(rec):
461
+ _apply_observation(conn, rec, segment, offset)
462
+ except OSError:
463
+ return None
464
+ if authoritative or cursor != (str(hw[0]), int(hw[1])):
465
+ _store_cursor(conn, hw, fingerprint)
466
+ if authoritative:
467
+ _meta_set(conn, CONFLICTS_KEY,
468
+ {"count": conflicts, "last_at": last_conflict_at})
469
+ elif conflicts:
470
+ _bump_counter(conn, CONFLICTS_KEY, conflicts, last_conflict_at)
471
+ return (str(hw[0]), int(hw[1]))
472
+
473
+
474
+ def decision_conflicts(conn) -> int:
475
+ doc = _meta_json(conn, CONFLICTS_KEY)
476
+ if isinstance(doc, dict) and type(doc.get("count")) is int:
477
+ return doc["count"]
478
+ return 0
479
+
480
+
481
+ # --------------------------------------------------------------------------
482
+ # timeline neighbours
483
+ # --------------------------------------------------------------------------
484
+
485
+ def _neighbours_with_evidence(conn, rec):
486
+ rec_lo, rec_hi = int(rec[0]), int(rec[1])
487
+ groups = {}
488
+
489
+ def add(row):
490
+ captured, precision, account, segment, offset = row
491
+ lo = K.us_from_iso(captured)
492
+ key = (lo, int(precision))
493
+ entry = groups.setdefault(key, [set(), []])
494
+ entry[0].add(account)
495
+ entry[1].append([captured, segment, int(offset)])
496
+ return lo, lo + int(precision)
497
+
498
+ best = None # (hi, lo, precision) of the latest instant before R
499
+ for row in conn.execute(
500
+ "SELECT captured_at_utc, precision_us, account_key,"
501
+ " journal_segment, journal_offset FROM claude_account_timeline"
502
+ " WHERE captured_at_utc < ? ORDER BY captured_at_utc DESC",
503
+ (K.iso_from_us(rec_lo),)):
504
+ lo = K.us_from_iso(row[0])
505
+ if best is not None and lo + _MAX_PRECISION_US < best[0]:
506
+ # No instant this early can end later than the latest one found,
507
+ # nor reach into R.
508
+ break
509
+ lo, hi = add(row)
510
+ if hi <= rec_lo and (best is None or (hi, lo) > best[:2]):
511
+ best = (hi, lo, hi - lo)
512
+ after = None
513
+ for row in conn.execute(
514
+ "SELECT captured_at_utc, precision_us, account_key,"
515
+ " journal_segment, journal_offset FROM claude_account_timeline"
516
+ " WHERE captured_at_utc >= ? ORDER BY captured_at_utc ASC",
517
+ (K.iso_from_us(rec_lo),)):
518
+ lo = K.us_from_iso(row[0])
519
+ if after is not None and lo > after[0]:
520
+ break
521
+ lo, hi = add(row)
522
+ if lo >= rec_hi:
523
+ candidate = (lo, hi - lo)
524
+ if after is None or candidate < after:
525
+ after = candidate
526
+
527
+ def instant(key):
528
+ accounts, _evidence = groups[key]
529
+ return K.Instant(key[0], key[1], frozenset(accounts))
530
+
531
+ before = None if best is None else instant((best[1], best[2]))
532
+ overlapping = tuple(
533
+ instant(key) for key in sorted(groups)
534
+ if K.relation((key[0], key[0] + key[1]), (rec_lo, rec_hi))
535
+ == "overlapping")
536
+ after_instant = None if after is None else instant(after)
537
+ evidence = {key: value[1] for key, value in groups.items()}
538
+ return K.Neighbours(before, overlapping, after_instant), evidence
539
+
540
+
541
+ def timeline_neighbours(conn, rec) -> K.Neighbours:
542
+ """``Pb``, ``Ov`` and ``Sa`` of a record interval ``rec`` by indexed
543
+ seeks around it (spec 5.2). The timeline is never loaded whole: the scan
544
+ before ``R`` stops once no earlier instant can end later than the latest
545
+ one found, and the scan after stops past the earliest instant after
546
+ ``R``."""
547
+ return _neighbours_with_evidence(conn, rec)[0]
548
+
549
+
550
+ def _supporting(evidence, neighbours) -> list:
551
+ out = []
552
+ instants = [neighbours.before, *neighbours.overlapping, neighbours.after]
553
+ for instant in instants:
554
+ if instant is None:
555
+ continue
556
+ out.extend(evidence.get((instant.lo_us, instant.precision_us), ()))
557
+ return out
558
+
559
+
560
+ def neighbour_evidence(conn, rec, neighbours) -> list:
561
+ """The ``[captured_at, segment, offset]`` entries supporting every instant
562
+ of ``neighbours`` (an agreement's evidence)."""
563
+ _found, evidence = _neighbours_with_evidence(conn, rec)
564
+ return _supporting(evidence, neighbours)
565
+
566
+
567
+ def timeline_neighbours_with_evidence(conn, rec):
568
+ """``(neighbours, evidence)`` of ``rec`` from ONE pair of seeks: the
569
+ ``timeline_neighbours`` result and the supporting entries of every
570
+ instant it names (what an agreement journals)."""
571
+ found, evidence = _neighbours_with_evidence(conn, rec)
572
+ return found, _supporting(evidence, found)
573
+
574
+
575
+ # --------------------------------------------------------------------------
576
+ # lookup
577
+ # --------------------------------------------------------------------------
578
+
579
+ _DECISION_COLUMNS = ("decision_key, account_key, basis, reason,"
580
+ " decided_at_utc, journal_segment, journal_offset")
581
+
582
+
583
+ def _stored(row) -> StoredDecision:
584
+ return StoredDecision(key=row[0], account_key=row[1], basis=row[2],
585
+ reason=row[3], decided_at_utc=row[4],
586
+ journal_pos=(row[5], int(row[6])))
587
+
588
+
589
+ def lookup_decision(conn, identity: RecordIdentity) -> "StoredDecision | None":
590
+ """The decision ``identity`` holds, in the spec 5.3 lookup order: the
591
+ keyed form, otherwise the physical form (exact raw bytes), then a legacy
592
+ decision at the same ``(loc, offset)`` whose projection matches -- an
593
+ exact match before a recorded-unknown-split one, then earliest journal
594
+ position."""
595
+ if identity.form in (K.FORM_KEYED, K.FORM_PHYSICAL):
596
+ row = conn.execute(
597
+ f"SELECT {_DECISION_COLUMNS} FROM claude_cost_decisions "
598
+ "WHERE decision_key=?", (identity.key,)).fetchone()
599
+ if row is not None:
600
+ return _stored(row)
601
+ if identity.form == K.FORM_KEYED:
602
+ return None
603
+ if identity.projection is None or identity.loc is None:
604
+ return None
605
+ candidates = []
606
+ for row in conn.execute(
607
+ f"SELECT {_DECISION_COLUMNS}, projection_json FROM "
608
+ "claude_cost_decisions WHERE loc=? AND byte_offset=? AND form=?",
609
+ (identity.loc, int(identity.offset), K.FORM_LEGACY)):
610
+ try:
611
+ projection = json.loads(row[7])
612
+ except (TypeError, ValueError):
613
+ continue
614
+ kind = K.legacy_match(projection, list(identity.projection))
615
+ candidates.append((kind, (row[5], int(row[6])), row))
616
+ ordered = K.order_legacy_candidates(candidates)
617
+ return _stored(ordered[0][2]) if ordered else None
618
+
619
+
620
+ # --------------------------------------------------------------------------
621
+ # append
622
+ # --------------------------------------------------------------------------
623
+
624
+ def _item_for(identity: RecordIdentity, basis: str, ts):
625
+ if identity.form == K.FORM_KEYED:
626
+ if basis == K.BASIS_PRESERVED:
627
+ return ["k1", identity.msg_id, identity.req_id]
628
+ return ["k1", identity.msg_id, identity.req_id, ts]
629
+ if identity.form == K.FORM_PHYSICAL:
630
+ return ["p1", identity.loc, int(identity.offset), identity.digest, ts]
631
+ return ["l1", identity.loc, int(identity.offset), identity.digest,
632
+ list(identity.projection)]
633
+
634
+
635
+ def _capture_doc(capture: "K.ClosingCapture | None"):
636
+ if capture is None:
637
+ return None
638
+ doc = {"at": K.iso_from_us(capture.at_us)}
639
+ if capture.account_key is not None:
640
+ doc["account_key"] = capture.account_key
641
+ return doc
642
+
643
+
644
+ def safe_high_water():
645
+ """The journal's safe high-water, or the current segment at offset 0 when
646
+ no journal exists yet; ``AttributionJournalUnavailable`` when unreadable."""
647
+ import _cctally_journal as jr
648
+ try:
649
+ hw = jr.journal_safe_high_water()
650
+ except OSError as exc:
651
+ raise AttributionJournalUnavailable(
652
+ f"the journal could not be read: {exc}") from exc
653
+ if hw is None:
654
+ return _empty_journal_high_water()
655
+ return (str(hw[0]), int(hw[1]))
656
+
657
+
658
+ def _empty_journal_high_water():
659
+ """The high-water of a journal that does not exist yet: the current
660
+ segment at offset 0."""
661
+ return (_lib_journal.segment_name(dt.datetime.now(dt.timezone.utc)), 0)
662
+
663
+
664
+ def _encoded_len(record) -> int:
665
+ return len(_lib_journal.encode_line(_lib_journal.prepare_record(record)))
666
+
667
+
668
+ def _json_len(value) -> int:
669
+ """The UTF-8 length of ``value`` in the journal's canonical encoding,
670
+ which is also its length as an element of any list in a journal line."""
671
+ return len(_lib_journal._canonical_json(value).encode("utf-8"))
672
+
673
+
674
+ #: What a line gains when its payload's ``evidence`` goes from absent to
675
+ #: present, beyond its entries and one comma each: ``,"evidence":[]`` less
676
+ #: the one comma the entries over-count (n entries need n-1 separators).
677
+ _EVIDENCE_KEY_LEN = len(',"evidence":[]') - 1
678
+
679
+
680
+ def _build_batches(groups, *, decided_at, journal_hw, closing_capture,
681
+ evidence):
682
+ """One op per homogeneous group, split so every line stays under the
683
+ budget. Returns ``[(record, [(identity, item), ...]), ...]``.
684
+
685
+ The partition is the greedy rule: members are added in order, and one
686
+ that would take the encoded line over ``_BATCH_LINE_BUDGET`` starts the
687
+ next batch (a lone member is always emitted, even over budget). An
688
+ agreement batch carries the deduplicated union of its members' evidence
689
+ in first-seen order; other bases carry none.
690
+
691
+ Linear: each batch's line length is measured once from a one-item
692
+ skeleton and then grown by exact arithmetic (canonical JSON appends an
693
+ element as its own encoding plus one comma), so validation runs once per
694
+ emitted batch. Backstop: a built line whose length differs from the
695
+ prediction, or that is over budget with more than one member, makes the
696
+ group fall back to the exact greedy rebuild, so the budget can never be
697
+ exceeded silently and the output stays the greedy rule's."""
698
+ out = []
699
+ for (basis, reason, account, uses_capture), members in groups.items():
700
+ capture = _capture_doc(closing_capture) if uses_capture else None
701
+ agreement = basis == K.BASIS_AGREEMENT
702
+
703
+ def build(chunk, factory=_lib_journal.make_claude_cost_account):
704
+ chunk_evidence = []
705
+ seen = set()
706
+ for _identity, _item, item_evidence in chunk:
707
+ for entry in item_evidence:
708
+ marker = tuple(entry)
709
+ if marker not in seen:
710
+ seen.add(marker)
711
+ chunk_evidence.append(list(entry))
712
+ return factory(
713
+ basis=basis, reason=reason, account_key=account,
714
+ decided_at=decided_at, journal_hw=journal_hw,
715
+ closing_capture=capture,
716
+ evidence=chunk_evidence if agreement else [],
717
+ items=[item for _identity, item, _e in chunk])
718
+
719
+ group_out = _linear_batches(members, build, agreement)
720
+ if group_out is None:
721
+ group_out = _greedy_batches(members, build)
722
+ out.extend(group_out)
723
+ return out
724
+
725
+
726
+ def _linear_batches(members, build, agreement):
727
+ """The greedy partition of one group, sized by arithmetic; ``None`` when
728
+ a built line disagrees with its predicted length (the backstop)."""
729
+ budget = _BATCH_LINE_BUDGET
730
+ out = []
731
+ chunk, seen, length = [], set(), 0
732
+
733
+ def emit():
734
+ record = build(chunk)
735
+ actual = _encoded_len(record)
736
+ if actual != length or (len(chunk) > 1 and actual > budget):
737
+ return False
738
+ out.append((record, chunk))
739
+ return True
740
+
741
+ for member in members:
742
+ item_evidence = member[2] if agreement else ()
743
+ if chunk:
744
+ fresh, fresh_seen = [], set()
745
+ for entry in item_evidence:
746
+ marker = tuple(entry)
747
+ if marker not in seen and marker not in fresh_seen:
748
+ fresh_seen.add(marker)
749
+ fresh.append(entry)
750
+ grown = length + _json_len(member[1]) + 1
751
+ if fresh:
752
+ grown += sum(_json_len(list(entry)) + 1 for entry in fresh)
753
+ if not seen:
754
+ grown += _EVIDENCE_KEY_LEN
755
+ if grown <= budget:
756
+ chunk.append(member)
757
+ seen |= fresh_seen
758
+ length = grown
759
+ continue
760
+ if not emit():
761
+ return None
762
+ chunk = [member]
763
+ seen = {tuple(entry) for entry in item_evidence}
764
+ length = _encoded_len(build(
765
+ chunk, _lib_journal.claude_cost_account_op_unvalidated))
766
+ if chunk and not emit():
767
+ return None
768
+ return out
769
+
770
+
771
+ def _greedy_batches(members, build):
772
+ """The exact greedy partition of one group, rebuilding the growing batch
773
+ per member (quadratic); only the backstop of ``_linear_batches``."""
774
+ out = []
775
+ chunk = []
776
+ for member in members:
777
+ chunk.append(member)
778
+ if len(chunk) > 1 and _encoded_len(build(chunk)) > (
779
+ _BATCH_LINE_BUDGET):
780
+ chunk.pop()
781
+ out.append((build(chunk), chunk))
782
+ chunk = [member]
783
+ if chunk:
784
+ out.append((build(chunk), chunk))
785
+ return out
786
+
787
+
788
+ def append_decisions(conn, decisions: Sequence, *, journal_hw,
789
+ closing_capture: "K.ClosingCapture | None",
790
+ evidence: list) -> list:
791
+ """Make ``decisions`` durable, journal first: append their
792
+ ``claude_cost_account`` batches in ONE ``append_records`` call (fsync),
793
+ then insert them into ``claude_cost_decisions`` on the caller's open
794
+ transaction. Returns the ``StoredDecision`` each identity now holds, in
795
+ input order (first-wins: an existing decision for the same key is kept).
796
+
797
+ Each element is ``(identity, decision, evaluated_timestamp)`` or
798
+ ``(identity, decision, evaluated_timestamp, item_evidence)``; the
799
+ timestamp is ``None`` for ``preserved_history``. ``evidence`` is the
800
+ supporting-observation list of every agreement lacking its own;
801
+ ``closing_capture`` applies to decisions that used it. Batches group by
802
+ basis, reason, account and closing capture.
803
+
804
+ Raises ``AttributionJournalUnavailable`` when the append fails; nothing
805
+ is inserted then."""
806
+ if not decisions:
807
+ return []
808
+ import _cctally_journal as jr
809
+ decided_at = _now_iso()
810
+ groups = {}
811
+ order = []
812
+ for element in decisions:
813
+ identity, decision, ts = element[0], element[1], element[2]
814
+ item_evidence = element[3] if len(element) > 3 else evidence
815
+ if decision.basis != K.BASIS_AGREEMENT:
816
+ item_evidence = ()
817
+ group = (decision.basis, decision.reason, decision.account_key,
818
+ bool(decision.used_closing_capture))
819
+ groups.setdefault(group, []).append(
820
+ (identity, _item_for(identity, decision.basis, ts),
821
+ tuple(tuple(e) for e in (item_evidence or ()))))
822
+ order.append(identity.key)
823
+ hw = (str(journal_hw[0]), int(journal_hw[1]))
824
+ batches = _build_batches(groups, decided_at=decided_at, journal_hw=hw,
825
+ closing_capture=closing_capture,
826
+ evidence=evidence)
827
+ records = [_lib_journal.prepare_record(record) for record, _m in batches]
828
+ lengths = [len(_lib_journal.encode_line(record)) for record in records]
829
+ try:
830
+ segment, end = jr.append_records(records)
831
+ except (OSError, jr.JournalError) as exc:
832
+ raise AttributionJournalUnavailable(
833
+ f"the journal could not be written: {exc}") from exc
834
+ start = end - sum(lengths)
835
+ conflicts = 0
836
+ for (record, _members), length in zip(batches, lengths):
837
+ conflicts += _insert_decision_items(
838
+ conn, record["payload"], segment, start)
839
+ start += length
840
+ if conflicts:
841
+ _bump_counter(conn, CONFLICTS_KEY, conflicts, decided_at)
842
+ stored = {}
843
+ for key in order:
844
+ if key in stored:
845
+ continue
846
+ row = conn.execute(
847
+ f"SELECT {_DECISION_COLUMNS} FROM claude_cost_decisions "
848
+ "WHERE decision_key=?", (key,)).fetchone()
849
+ stored[key] = _stored(row)
850
+ return [stored[key] for key in order]
851
+
852
+
853
+ # --------------------------------------------------------------------------
854
+ # alignment and preservation
855
+ # --------------------------------------------------------------------------
856
+
857
+ def _bump_mutation_seq(conn) -> int:
858
+ """The ``session_entries`` mutation counter, advanced exactly as an
859
+ in-place upsert advances it (``_cctally_cache._bump_mutation_seq``)."""
860
+ row = conn.execute(
861
+ "INSERT INTO cache_meta(key, value) VALUES (?, '1') "
862
+ "ON CONFLICT(key) DO UPDATE SET "
863
+ " value = CAST(cache_meta.value AS INTEGER) + 1 "
864
+ "RETURNING value", (MUTATION_SEQ_KEY,)).fetchone()
865
+ return int(row[0])
866
+
867
+
868
+ def align_row(conn, row_id: int, decision: StoredDecision) -> bool:
869
+ """Link an unlinked accounting row to the decision its identity already
870
+ holds, setting the row's account to the decision's (spec 5.4). An
871
+ account change sets the row's mutation signal and advances the mutation
872
+ counter exactly as a successful in-place upsert does, and is counted in
873
+ ``claude_cost_aligned_rows``; a link alone changes no cost and no
874
+ account, so it advances nothing. Returns whether the account changed.
875
+
876
+ Accounts compare under the read rule (``K.preserved_stamp``): a literal
877
+ ``unattributed`` or empty stamp already is the NULL account a decision
878
+ carries, so its row keeps its bytes. Rewriting them would change no
879
+ account but would drift a reviewed rederive's cache fingerprint."""
880
+ row = conn.execute(
881
+ "SELECT account_key FROM session_entries WHERE id=?",
882
+ (int(row_id),)).fetchone()
883
+ if row is None:
884
+ return False
885
+ changed = K.preserved_stamp(row[0]) != decision.account_key
886
+ if changed:
887
+ seq = _bump_mutation_seq(conn)
888
+ conn.execute(
889
+ "UPDATE session_entries SET account_key=?,"
890
+ " attribution_decision_key=?, mutation_seq=?,"
891
+ " mutation_min_ts=MIN(COALESCE(mutation_min_ts, timestamp_utc),"
892
+ " timestamp_utc) WHERE id=?",
893
+ (decision.account_key, decision.key, seq, int(row_id)))
894
+ _bump_counter(conn, ALIGNED_KEY, 1, _now_iso())
895
+ else:
896
+ conn.execute(
897
+ "UPDATE session_entries SET attribution_decision_key=? WHERE id=?",
898
+ (decision.key, int(row_id)))
899
+ return changed
900
+
901
+
902
+ def _unlinked_rows(conn, *, source_paths, after_id, limit):
903
+ sql = (f"SELECT id, source_path, line_offset, msg_id, req_id, account_key,"
904
+ f" {_PROJECTION_COLUMNS} FROM session_entries "
905
+ "WHERE attribution_decision_key IS NULL AND id > ?")
906
+ params = [int(after_id)]
907
+ if source_paths is not None:
908
+ sql += " AND source_path IN (%s)" % ",".join("?" * len(source_paths))
909
+ params.extend(source_paths)
910
+ sql += " ORDER BY id LIMIT ?"
911
+ params.append(int(limit))
912
+ cursor = conn.execute(sql, params)
913
+ names = [d[0] for d in cursor.description]
914
+ return [dict(zip(names, row)) for row in cursor.fetchall()]
915
+
916
+
917
+ def _unlinked_remaining(conn, *, source_paths, after_id) -> bool:
918
+ sql = ("SELECT 1 FROM session_entries WHERE attribution_decision_key "
919
+ "IS NULL AND id > ?")
920
+ params = [int(after_id)]
921
+ if source_paths is not None:
922
+ sql += " AND source_path IN (%s)" % ",".join("?" * len(source_paths))
923
+ params.extend(source_paths)
924
+ return conn.execute(sql + " LIMIT 1", params).fetchone() is not None
925
+
926
+
927
+ def preserve_unlinked_rows(conn, *, journal_hw,
928
+ source_paths: "Sequence[str] | None" = None,
929
+ after_id: int = 0, limit: "int | None" = None
930
+ ) -> PreserveResult:
931
+ """Link every unlinked ``session_entries`` row (``id > after_id``, at
932
+ most ``limit``, optionally only those of ``source_paths``) in ``id``
933
+ order: a row whose identity already holds a decision is aligned to it;
934
+ every other row's CURRENT stamp (NULL included) is exported as a
935
+ ``preserved_history`` decision -- keyed form for a row with both ids,
936
+ legacy projection form otherwise -- appended (fsync) before its link is
937
+ set. Never consults the kernel and never reads transcript bytes.
938
+ ``journal_hw`` is the high-water the caller's preceding replay
939
+ (``rehydrate_claude_attribution``) consumed; every export records it.
940
+
941
+ Runs on the caller's transaction and never commits, so the caller
942
+ commits the links with whatever progress it records. Raises
943
+ ``AttributionJournalUnavailable`` when an append fails; the caller rolls
944
+ back, and decisions already appended are recovered by the next replay."""
945
+ if source_paths is not None:
946
+ source_paths = list(source_paths)
947
+ if not source_paths:
948
+ return PreserveResult(0, 0, 0, int(after_id), False)
949
+ saved = aligned = exported = 0
950
+ last_id = int(after_id)
951
+ budget = None if limit is None else int(limit)
952
+ hw = (str(journal_hw[0]), int(journal_hw[1]))
953
+ while budget is None or budget > 0:
954
+ size = _PRESERVE_CHUNK if budget is None else min(
955
+ budget, _PRESERVE_CHUNK)
956
+ rows = _unlinked_rows(conn, source_paths=source_paths,
957
+ after_id=last_id, limit=size)
958
+ if not rows:
959
+ break
960
+ to_export = []
961
+ for row in rows:
962
+ identity = row_identity(row)
963
+ found = lookup_decision(conn, identity)
964
+ if found is not None:
965
+ align_row(conn, row["id"], found)
966
+ aligned += 1
967
+ else:
968
+ to_export.append((row, identity))
969
+ if to_export:
970
+ # One identity can appear once per chunk at most: every identity
971
+ # names at most one accounting row (spec 5.4).
972
+ # A literal ``unattributed`` stamp (read paths treat it as NULL),
973
+ # an empty or a non-string stamp is preserved as NULL: a decision
974
+ # never carries any of them. Its row keeps its bytes (align_row).
975
+ stored = append_decisions(
976
+ conn,
977
+ [(identity, K.Decision(
978
+ K.preserved_stamp(row["account_key"]),
979
+ K.BASIS_PRESERVED, None, False), None)
980
+ for row, identity in to_export],
981
+ journal_hw=hw, closing_capture=None, evidence=[])
982
+ for (row, _identity), decision in zip(to_export, stored):
983
+ if decision.account_key == K.preserved_stamp(
984
+ row["account_key"]):
985
+ conn.execute(
986
+ "UPDATE session_entries SET attribution_decision_key=?"
987
+ " WHERE id=?", (decision.key, row["id"]))
988
+ else:
989
+ align_row(conn, row["id"], decision)
990
+ exported += len(to_export)
991
+ saved += len(rows)
992
+ last_id = int(rows[-1]["id"])
993
+ if budget is not None:
994
+ budget -= len(rows)
995
+ remaining = _unlinked_remaining(conn, source_paths=source_paths,
996
+ after_id=last_id)
997
+ return PreserveResult(saved, aligned, exported, last_id, remaining)