cctally 1.97.0 → 1.99.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.
@@ -76,7 +76,7 @@ import tempfile
76
76
  import time
77
77
  import traceback
78
78
  from dataclasses import dataclass
79
- from typing import Any, Callable
79
+ from typing import Any, Callable, NamedTuple
80
80
 
81
81
 
82
82
  def _cctally():
@@ -745,6 +745,42 @@ def _would_block_prod_migration(conn: sqlite3.Connection) -> bool:
745
745
  return False
746
746
 
747
747
 
748
+ def _refuse_prod_migration_before_schema_write(
749
+ conn: sqlite3.Connection,
750
+ registry: "list[Migration]",
751
+ db_label: str,
752
+ ) -> None:
753
+ """Raise ``ProdMigrationRefused`` BEFORE the caller writes policy or DDL.
754
+
755
+ The #142 guard lives inside ``_run_pending_migrations``, which both
756
+ cache-open paths reach only AFTER ``apply_policy``, ``_apply_cache_schema``
757
+ and the ``last_total_tokens`` ALTER-plus-purge. A dev-checkout binary
758
+ pointed at the real prod dir therefore modified the production schema and
759
+ only then refused (#566). This preflight is the same decision, evaluated
760
+ where nothing has been written yet.
761
+
762
+ The conditions are the dispatcher's, unchanged: there must be pending
763
+ migrations that would advance ``user_version``
764
+ (``cur_version < len(registry)``), and ``_would_block_prod_migration`` must
765
+ hold — connection-scoped, password-DB-resolved, suppressor-independent,
766
+ with ``CCTALLY_ALLOW_PROD_MIGRATION`` as the escape.
767
+
768
+ A version-AHEAD store is deliberately NOT refused here. That is the #145
769
+ self-heal case, which the dispatcher owns and which cache.db opts into.
770
+
771
+ Callers must already hold cache maintenance exclusive and the global writer
772
+ flock, so the version this reads is the one the upgrade would act on.
773
+ """
774
+ cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
775
+ if cur_version >= len(registry):
776
+ return
777
+ if not _would_block_prod_migration(conn):
778
+ return
779
+ raise ProdMigrationRefused(
780
+ db_label, _first_pending_migration_name(conn, registry, cur_version)
781
+ )
782
+
783
+
748
784
  def _would_block_prod_stats(path: pathlib.Path) -> bool:
749
785
  """Path-based sibling of ``_would_block_prod_migration`` for the stats.db
750
786
  classifier-gated auto-heal + ``db rebuild --db stats`` (spec §6.3, issue
@@ -3843,6 +3879,25 @@ def _apply_codex_quota_unresolved_model_index(conn: sqlite3.Connection) -> None:
3843
3879
  conn.execute(_QUOTA_UNRESOLVED_MODEL_INDEX_DDL)
3844
3880
 
3845
3881
 
3882
+ def _apply_codex_entries_root_path_index(conn: sqlite3.Connection) -> None:
3883
+ """Create the per-file alias join index (#566).
3884
+
3885
+ ``_codex_conversation_metadata`` matches on (source_root_key, source_path).
3886
+ ``idx_codex_entries_source_root`` cannot serve it: a machine normally has
3887
+ ONE provider root, so a root-only search visits every entry row for every
3888
+ file and the join costs files x entries on every dashboard snapshot build.
3889
+ Measured on a real store, the alias query alone went 57.20s -> 0.208s.
3890
+
3891
+ Called from BOTH ``_apply_cache_schema`` and cache migration 042 so the two
3892
+ delivery paths cannot drift. Creating it on a 153K-row store costs 0.23s;
3893
+ re-running is free.
3894
+ """
3895
+ conn.execute(
3896
+ "CREATE INDEX IF NOT EXISTS idx_codex_entries_root_path "
3897
+ "ON codex_session_entries(source_root_key, source_path)"
3898
+ )
3899
+
3900
+
3846
3901
  def _apply_codex_quota_change_ledger(conn: sqlite3.Connection) -> None:
3847
3902
  """Create the Codex quota change ledger + triggers, idempotently.
3848
3903
 
@@ -3861,9 +3916,486 @@ def _apply_codex_quota_change_ledger(conn: sqlite3.Connection) -> None:
3861
3916
  conn.execute(statement)
3862
3917
 
3863
3918
 
3919
+ _CODEX_ACCOUNTING_SEMANTIC_COLUMNS = (
3920
+ "source_path",
3921
+ "source_root_key",
3922
+ "timestamp_utc",
3923
+ "session_id",
3924
+ "model",
3925
+ "input_tokens",
3926
+ "cached_input_tokens",
3927
+ "output_tokens",
3928
+ "reasoning_output_tokens",
3929
+ "total_tokens",
3930
+ "account_key",
3931
+ "conversation_key",
3932
+ )
3933
+
3934
+
3935
+ def _codex_accounting_ledger_ddl() -> tuple[str, ...]:
3936
+ """DDL for #582's path-granular Codex accounting dirty signal.
3937
+
3938
+ The counter is independent of ``codex_physical_mutation_seq``: the latter
3939
+ covers quota, cursors and metadata too, while this sequence advances only
3940
+ when an accounting row's rendered semantics move. UPDATE records both row
3941
+ images under one sequence; the UNIQUE constraint collapses them when the
3942
+ physical path itself did not change (the ordinary account-adoption case).
3943
+ """
3944
+ update_of = ", ".join(_CODEX_ACCOUNTING_SEMANTIC_COLUMNS)
3945
+ changed = " OR ".join(
3946
+ f"OLD.{name} IS NOT NEW.{name}"
3947
+ for name in _CODEX_ACCOUNTING_SEMANTIC_COLUMNS
3948
+ )
3949
+ # The row is installed before every trigger below. Use a plain UPDATE:
3950
+ # SQLite propagates an outer statement's conflict policy into trigger-body
3951
+ # INSERT/UPSERT statements, so a thread `INSERT ... ON CONFLICT DO UPDATE`
3952
+ # could suppress this bump and make the following ledger insert collide
3953
+ # with the prior sequence. UPDATE has no conflict branch for the outer
3954
+ # policy to override.
3955
+ bump = (
3956
+ "UPDATE cache_meta SET value=CAST(value AS INTEGER) + 1 "
3957
+ "WHERE key='codex_accounting_mutation_seq';"
3958
+ )
3959
+ def thread_paths(image: str) -> str:
3960
+ return f"""
3961
+ SELECT DISTINCT entries.source_root_key, entries.source_path
3962
+ FROM codex_session_entries AS entries
3963
+ LEFT JOIN codex_session_files AS files
3964
+ ON files.source_root_key = entries.source_root_key
3965
+ AND files.path = entries.source_path
3966
+ WHERE (
3967
+ entries.source_root_key = {image}.source_root_key
3968
+ AND entries.conversation_key = {image}.conversation_key
3969
+ ) OR (
3970
+ files.source_root_key = {image}.source_root_key
3971
+ AND files.last_native_thread_id = {image}.native_thread_id
3972
+ )
3973
+ """
3974
+ old_thread_paths = thread_paths("OLD")
3975
+ new_thread_paths = thread_paths("NEW")
3976
+ thread_changed = " OR ".join(
3977
+ f"OLD.{name} IS NOT NEW.{name}"
3978
+ for name in (
3979
+ "conversation_key", "source_root_key", "native_thread_id", "cwd",
3980
+ "git_json", "last_seen_utc",
3981
+ )
3982
+ )
3983
+ def file_has_entries(image: str) -> str:
3984
+ return f"""
3985
+ SELECT 1 FROM codex_session_entries AS entries
3986
+ WHERE entries.source_root_key = {image}.source_root_key
3987
+ AND entries.source_path = {image}.path
3988
+ """
3989
+ old_file_entries = file_has_entries("OLD")
3990
+ new_file_entries = file_has_entries("NEW")
3991
+ file_changed = " OR ".join(
3992
+ f"OLD.{name} IS NOT NEW.{name}"
3993
+ for name in ("path", "source_root_key", "last_native_thread_id")
3994
+ )
3995
+ return (
3996
+ """
3997
+ CREATE TABLE IF NOT EXISTS codex_accounting_change_log (
3998
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3999
+ mutation_seq INTEGER NOT NULL,
4000
+ change_kind TEXT NOT NULL
4001
+ CHECK(change_kind IN ('path','full')),
4002
+ source_root_key TEXT,
4003
+ source_path TEXT,
4004
+ UNIQUE(mutation_seq, change_kind, source_root_key, source_path)
4005
+ )
4006
+ """,
4007
+ "CREATE INDEX IF NOT EXISTS idx_codex_accounting_change_mutation "
4008
+ "ON codex_accounting_change_log(mutation_seq, seq)",
4009
+ "INSERT OR IGNORE INTO cache_meta(key, value) VALUES "
4010
+ "('codex_accounting_mutation_seq', '0')",
4011
+ "DROP TRIGGER IF EXISTS trg_codex_accounting_ins",
4012
+ f"""
4013
+ CREATE TRIGGER trg_codex_accounting_ins
4014
+ AFTER INSERT ON codex_session_entries
4015
+ WHEN NOT EXISTS (
4016
+ SELECT 1 FROM cache_meta
4017
+ WHERE key='codex_accounting_bulk_clear'
4018
+ )
4019
+ BEGIN
4020
+ {bump}
4021
+ INSERT OR IGNORE INTO codex_accounting_change_log
4022
+ (mutation_seq, change_kind, source_root_key, source_path)
4023
+ SELECT CAST(value AS INTEGER), 'path',
4024
+ COALESCE(NEW.source_root_key, ''), NEW.source_path
4025
+ FROM cache_meta
4026
+ WHERE key='codex_accounting_mutation_seq';
4027
+ END
4028
+ """,
4029
+ "DROP TRIGGER IF EXISTS trg_codex_accounting_del",
4030
+ f"""
4031
+ CREATE TRIGGER trg_codex_accounting_del
4032
+ AFTER DELETE ON codex_session_entries
4033
+ WHEN NOT EXISTS (
4034
+ SELECT 1 FROM cache_meta
4035
+ WHERE key='codex_accounting_bulk_clear'
4036
+ )
4037
+ BEGIN
4038
+ {bump}
4039
+ INSERT OR IGNORE INTO codex_accounting_change_log
4040
+ (mutation_seq, change_kind, source_root_key, source_path)
4041
+ SELECT CAST(value AS INTEGER), 'path',
4042
+ COALESCE(OLD.source_root_key, ''), OLD.source_path
4043
+ FROM cache_meta
4044
+ WHERE key='codex_accounting_mutation_seq';
4045
+ END
4046
+ """,
4047
+ "DROP TRIGGER IF EXISTS trg_codex_accounting_upd",
4048
+ f"""
4049
+ CREATE TRIGGER trg_codex_accounting_upd
4050
+ AFTER UPDATE OF {update_of} ON codex_session_entries
4051
+ WHEN ({changed}) AND NOT EXISTS (
4052
+ SELECT 1 FROM cache_meta
4053
+ WHERE key='codex_accounting_bulk_clear'
4054
+ )
4055
+ BEGIN
4056
+ {bump}
4057
+ INSERT OR IGNORE INTO codex_accounting_change_log
4058
+ (mutation_seq, change_kind, source_root_key, source_path)
4059
+ SELECT CAST(value AS INTEGER), 'path',
4060
+ COALESCE(OLD.source_root_key, ''), OLD.source_path
4061
+ FROM cache_meta
4062
+ WHERE key='codex_accounting_mutation_seq'
4063
+ UNION
4064
+ SELECT CAST(value AS INTEGER), 'path',
4065
+ COALESCE(NEW.source_root_key, ''), NEW.source_path
4066
+ FROM cache_meta
4067
+ WHERE key='codex_accounting_mutation_seq';
4068
+ END
4069
+ """,
4070
+ "DROP TRIGGER IF EXISTS trg_codex_accounting_thread_ins",
4071
+ f"""
4072
+ CREATE TRIGGER trg_codex_accounting_thread_ins
4073
+ AFTER INSERT ON codex_conversation_threads
4074
+ WHEN EXISTS ({new_thread_paths})
4075
+ BEGIN
4076
+ {bump}
4077
+ INSERT OR IGNORE INTO codex_accounting_change_log
4078
+ (mutation_seq, change_kind, source_root_key, source_path)
4079
+ SELECT CAST(meta.value AS INTEGER), 'path', paths.source_root_key,
4080
+ paths.source_path
4081
+ FROM ({new_thread_paths}) AS paths
4082
+ JOIN cache_meta AS meta
4083
+ ON meta.key='codex_accounting_mutation_seq';
4084
+ END
4085
+ """,
4086
+ "DROP TRIGGER IF EXISTS trg_codex_accounting_thread_del",
4087
+ f"""
4088
+ CREATE TRIGGER trg_codex_accounting_thread_del
4089
+ AFTER DELETE ON codex_conversation_threads
4090
+ WHEN EXISTS ({old_thread_paths})
4091
+ BEGIN
4092
+ {bump}
4093
+ INSERT OR IGNORE INTO codex_accounting_change_log
4094
+ (mutation_seq, change_kind, source_root_key, source_path)
4095
+ SELECT CAST(meta.value AS INTEGER), 'path', paths.source_root_key,
4096
+ paths.source_path
4097
+ FROM ({old_thread_paths}) AS paths
4098
+ JOIN cache_meta AS meta
4099
+ ON meta.key='codex_accounting_mutation_seq';
4100
+ END
4101
+ """,
4102
+ "DROP TRIGGER IF EXISTS trg_codex_accounting_thread_upd",
4103
+ f"""
4104
+ CREATE TRIGGER trg_codex_accounting_thread_upd
4105
+ AFTER UPDATE OF conversation_key, source_root_key, native_thread_id,
4106
+ cwd, git_json, last_seen_utc
4107
+ ON codex_conversation_threads
4108
+ WHEN ({thread_changed})
4109
+ AND (EXISTS ({old_thread_paths}) OR EXISTS ({new_thread_paths}))
4110
+ BEGIN
4111
+ {bump}
4112
+ INSERT OR IGNORE INTO codex_accounting_change_log
4113
+ (mutation_seq, change_kind, source_root_key, source_path)
4114
+ SELECT CAST(meta.value AS INTEGER), 'path', paths.source_root_key,
4115
+ paths.source_path
4116
+ FROM ({old_thread_paths}) AS paths
4117
+ JOIN cache_meta AS meta
4118
+ ON meta.key='codex_accounting_mutation_seq'
4119
+ UNION
4120
+ SELECT CAST(meta.value AS INTEGER), 'path', paths.source_root_key,
4121
+ paths.source_path
4122
+ FROM ({new_thread_paths}) AS paths
4123
+ JOIN cache_meta AS meta
4124
+ ON meta.key='codex_accounting_mutation_seq';
4125
+ END
4126
+ """,
4127
+ "DROP TRIGGER IF EXISTS trg_codex_accounting_file_ins",
4128
+ f"""
4129
+ CREATE TRIGGER trg_codex_accounting_file_ins
4130
+ AFTER INSERT ON codex_session_files
4131
+ WHEN EXISTS ({new_file_entries})
4132
+ BEGIN
4133
+ {bump}
4134
+ INSERT OR IGNORE INTO codex_accounting_change_log
4135
+ (mutation_seq, change_kind, source_root_key, source_path)
4136
+ SELECT CAST(value AS INTEGER), 'path',
4137
+ COALESCE(NEW.source_root_key, ''), NEW.path
4138
+ FROM cache_meta
4139
+ WHERE key='codex_accounting_mutation_seq';
4140
+ END
4141
+ """,
4142
+ "DROP TRIGGER IF EXISTS trg_codex_accounting_file_del",
4143
+ f"""
4144
+ CREATE TRIGGER trg_codex_accounting_file_del
4145
+ AFTER DELETE ON codex_session_files
4146
+ WHEN EXISTS ({old_file_entries})
4147
+ BEGIN
4148
+ {bump}
4149
+ INSERT OR IGNORE INTO codex_accounting_change_log
4150
+ (mutation_seq, change_kind, source_root_key, source_path)
4151
+ SELECT CAST(value AS INTEGER), 'path',
4152
+ COALESCE(OLD.source_root_key, ''), OLD.path
4153
+ FROM cache_meta
4154
+ WHERE key='codex_accounting_mutation_seq';
4155
+ END
4156
+ """,
4157
+ "DROP TRIGGER IF EXISTS trg_codex_accounting_file_upd",
4158
+ f"""
4159
+ CREATE TRIGGER trg_codex_accounting_file_upd
4160
+ AFTER UPDATE OF path, source_root_key, last_native_thread_id
4161
+ ON codex_session_files
4162
+ WHEN ({file_changed})
4163
+ AND (EXISTS ({old_file_entries}) OR EXISTS ({new_file_entries}))
4164
+ BEGIN
4165
+ {bump}
4166
+ INSERT OR IGNORE INTO codex_accounting_change_log
4167
+ (mutation_seq, change_kind, source_root_key, source_path)
4168
+ SELECT CAST(meta.value AS INTEGER), 'path',
4169
+ COALESCE(OLD.source_root_key, ''), OLD.path
4170
+ FROM cache_meta AS meta
4171
+ WHERE meta.key='codex_accounting_mutation_seq'
4172
+ AND EXISTS ({old_file_entries})
4173
+ UNION
4174
+ SELECT CAST(meta.value AS INTEGER), 'path',
4175
+ COALESCE(NEW.source_root_key, ''), NEW.path
4176
+ FROM cache_meta AS meta
4177
+ WHERE meta.key='codex_accounting_mutation_seq'
4178
+ AND EXISTS ({new_file_entries});
4179
+ END
4180
+ """,
4181
+ )
4182
+
4183
+
4184
+ def _apply_codex_accounting_change_ledger(conn: sqlite3.Connection) -> None:
4185
+ """Install #582's accounting ledger on every schema delivery path."""
4186
+ entry_cols = {
4187
+ str(row[1]) for row in conn.execute(
4188
+ "PRAGMA table_info(codex_session_entries)")
4189
+ }
4190
+ file_cols = {
4191
+ str(row[1]) for row in conn.execute(
4192
+ "PRAGMA table_info(codex_session_files)")
4193
+ }
4194
+ if (
4195
+ not set(_CODEX_ACCOUNTING_SEMANTIC_COLUMNS) <= entry_cols
4196
+ or not {"path", "source_root_key", "last_native_thread_id"} <= file_cols
4197
+ ):
4198
+ return
4199
+ for statement in _codex_accounting_ledger_ddl():
4200
+ conn.execute(statement)
4201
+
4202
+
3864
4203
  # === Region 7b2: Eager cache-migration trigger (V4 — same-invocation 008 apply) ===
3865
4204
 
3866
4205
 
4206
+ class SchemaDeliveryObject(NamedTuple):
4207
+ """One re-derivable schema object declared by a version-gated apply.
4208
+
4209
+ ``kind`` is one of ``table``, ``view``, ``trigger`` or ``index``. The two
4210
+ store registries and their structural tests cover every explicit object of
4211
+ those kinds, including virtual tables while excluding SQLite-created FTS
4212
+ shadow objects.
4213
+
4214
+ ``ensure_helper`` names the shared module-level function that owns the DDL,
4215
+ or is ``None`` when the statement is written inline in the schema body.
4216
+ ``introduced_by`` names the store migration whose handler delivers the
4217
+ object to a store already stamped at the previous head, or is ``None`` for
4218
+ the archaeologically audited frozen baseline described below.
4219
+ """
4220
+
4221
+ kind: str
4222
+ name: str
4223
+ ensure_helper: "str | None"
4224
+ introduced_by: "str | None"
4225
+
4226
+
4227
+ #: Every table, view, trigger and index ``_apply_cache_schema`` declares, and
4228
+ #: how it reaches a store that is NOT newly created (#566, #580).
4229
+ #:
4230
+ #: The schema apply is version-gated: ``open_cache_db`` runs it only when the
4231
+ #: store's ``user_version`` differs from ``len(_CACHE_MIGRATIONS)``. So an object
4232
+ #: added to the schema body alone reaches new stores only — which is exactly
4233
+ #: what happened to ``idx_codex_entries_root_path``, absent from a real install
4234
+ #: running the release that shipped it. ``tests/test_cache_schema_delivery.py``
4235
+ #: compares this registry against the schema body in both directions, so an
4236
+ #: unregistered object fails a test instead of reaching users.
4237
+ #:
4238
+ #: THE BASELINE IS FROZEN. The ``introduced_by=None`` records are the objects
4239
+ #: that predate this registry and have no index-delivery migration; requiring
4240
+ #: provenance for them would mean fabricating history. A NEW object may never
4241
+ #: join them: anything added from now on needs a migration, because that is the
4242
+ #: only way an already-current store picks it up. The test pins every baseline
4243
+ #: identity so even a same-kind remove-one/add-one substitution fails.
4244
+ CACHE_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
4245
+ # ── frozen baseline: audited objects without handler-owned delivery DDL ──
4246
+ SchemaDeliveryObject("index", "idx_codex_conv_msgs_conversation", None, None),
4247
+ SchemaDeliveryObject("index", "idx_codex_conv_msgs_source", None, None),
4248
+ SchemaDeliveryObject("index", "idx_codex_conv_rollups_recent", None, None),
4249
+ SchemaDeliveryObject("index", "idx_codex_conv_touches_source", None, None),
4250
+ SchemaDeliveryObject("index", "idx_codex_entries_conversation", None, None),
4251
+ SchemaDeliveryObject("index", "idx_codex_entries_session", None, None),
4252
+ SchemaDeliveryObject("index", "idx_codex_entries_source", None, None),
4253
+ SchemaDeliveryObject("index", "idx_codex_entries_source_root", None, None),
4254
+ SchemaDeliveryObject("index", "idx_codex_entries_timestamp", None, None),
4255
+ SchemaDeliveryObject("index", "idx_codex_entries_ts_root_conversation", None, None),
4256
+ SchemaDeliveryObject("index", "idx_codex_events_conversation", None, None),
4257
+ SchemaDeliveryObject("index", "idx_codex_events_timestamp", None, None),
4258
+ SchemaDeliveryObject("index", "idx_codex_files_conversation", None, None),
4259
+ SchemaDeliveryObject("index", "idx_codex_files_source_root", None, None),
4260
+ SchemaDeliveryObject("index", "idx_codex_threads_source_path", None, None),
4261
+ SchemaDeliveryObject("index", "idx_codex_threads_source_root", None, None),
4262
+ SchemaDeliveryObject("index", "idx_conv_session_ts", None, None),
4263
+ SchemaDeliveryObject("index", "idx_conv_session_uuid", None, None),
4264
+ SchemaDeliveryObject("index", "idx_conv_sessions_recent", None, None),
4265
+ SchemaDeliveryObject("index", "idx_conv_source", None, None),
4266
+ SchemaDeliveryObject("index", "idx_conv_turnkey", None, None),
4267
+ SchemaDeliveryObject("index", "idx_entries_dedup", None, None),
4268
+ SchemaDeliveryObject("index", "idx_entries_mutation_seq", None, None),
4269
+ SchemaDeliveryObject("index", "idx_entries_source", None, None),
4270
+ SchemaDeliveryObject("index", "idx_entries_timestamp", None, None),
4271
+ SchemaDeliveryObject("index", "idx_file_touches_path", None, None),
4272
+ SchemaDeliveryObject("index", "idx_quota_window_captured_at", None, None),
4273
+ SchemaDeliveryObject("index", "idx_quota_window_source_root", None, None),
4274
+ SchemaDeliveryObject("index", "idx_session_files_session_id", None, None),
4275
+ SchemaDeliveryObject("table", "cache_meta", None, None),
4276
+ SchemaDeliveryObject("table", "codex_conversation_events", None, None),
4277
+ SchemaDeliveryObject("table", "codex_conversation_file_touches", None, None),
4278
+ SchemaDeliveryObject("table", "codex_conversation_fts", None, None),
4279
+ SchemaDeliveryObject("table", "codex_conversation_messages", None, None),
4280
+ SchemaDeliveryObject("table", "codex_conversation_rollups", None, None),
4281
+ SchemaDeliveryObject("table", "codex_conversation_threads", None, None),
4282
+ SchemaDeliveryObject("table", "codex_session_entries", None, None),
4283
+ SchemaDeliveryObject("table", "codex_session_files", None, None),
4284
+ SchemaDeliveryObject("table", "codex_source_roots", None, None),
4285
+ SchemaDeliveryObject("table", "conversation_ai_titles", None, None),
4286
+ SchemaDeliveryObject("table", "conversation_file_touches", None, None),
4287
+ SchemaDeliveryObject("table", "conversation_fts", None, None),
4288
+ SchemaDeliveryObject("table", "conversation_messages", None, None),
4289
+ SchemaDeliveryObject("table", "conversation_sessions", None, None),
4290
+ SchemaDeliveryObject("table", "conversation_title_fts", None, None),
4291
+ SchemaDeliveryObject("table", "quota_window_snapshots", None, None),
4292
+ SchemaDeliveryObject("table", "session_entries", None, None),
4293
+ SchemaDeliveryObject("table", "session_files", None, None),
4294
+ SchemaDeliveryObject("trigger", "codex_conv_fts_ad", None, None),
4295
+ SchemaDeliveryObject("trigger", "codex_conv_fts_ai", None, None),
4296
+ SchemaDeliveryObject("trigger", "codex_conv_fts_au", None, None),
4297
+ SchemaDeliveryObject("trigger", "conv_fts_ad", None, None),
4298
+ SchemaDeliveryObject("trigger", "conv_fts_ai", None, None),
4299
+ SchemaDeliveryObject("trigger", "conv_fts_au", None, None),
4300
+ SchemaDeliveryObject("trigger", "conv_title_fts_ad", None, None),
4301
+ SchemaDeliveryObject("trigger", "conv_title_fts_ai", None, None),
4302
+ SchemaDeliveryObject("trigger", "conv_title_fts_au", None, None),
4303
+ # ── post-baseline: each migration owns the object's delivery DDL ──
4304
+ SchemaDeliveryObject(
4305
+ "index", "idx_entries_physical", None,
4306
+ "020_session_entries_physical_unique"),
4307
+ SchemaDeliveryObject(
4308
+ "index", "idx_conversation_messages_cwd", None,
4309
+ "021_index_conversation_messages_cwd"),
4310
+ SchemaDeliveryObject(
4311
+ "index", "idx_conversation_messages_model_session", None,
4312
+ "022_index_conversation_messages_model"),
4313
+ SchemaDeliveryObject(
4314
+ "index", "idx_codex_file_accounts_root", None,
4315
+ "031_codex_file_account_map"),
4316
+ SchemaDeliveryObject(
4317
+ "index", "idx_qws_physical_group", "_apply_codex_quota_group_index",
4318
+ "040_codex_quota_physical_group_index"),
4319
+ SchemaDeliveryObject(
4320
+ "index", "idx_qws_unresolved_model",
4321
+ "_apply_codex_quota_unresolved_model_index",
4322
+ "041_codex_quota_unresolved_model_index"),
4323
+ SchemaDeliveryObject(
4324
+ "index", "idx_codex_entries_root_path",
4325
+ "_apply_codex_entries_root_path_index",
4326
+ "042_codex_entries_root_path_index"),
4327
+ SchemaDeliveryObject(
4328
+ "index", "idx_codex_window_attributions_root", None,
4329
+ "043_codex_window_attributions"),
4330
+ SchemaDeliveryObject(
4331
+ "index", "idx_codex_accounting_change_mutation",
4332
+ "_apply_codex_accounting_change_ledger",
4333
+ "044_codex_accounting_change_ledger"),
4334
+ SchemaDeliveryObject(
4335
+ "table", "codex_file_accounts", None,
4336
+ "031_codex_file_account_map"),
4337
+ SchemaDeliveryObject(
4338
+ "table", "codex_file_incarnations", None,
4339
+ "031_codex_file_account_map"),
4340
+ SchemaDeliveryObject(
4341
+ "table", "quota_window_change_log",
4342
+ "_apply_codex_quota_change_ledger",
4343
+ "037_codex_quota_change_ledger"),
4344
+ SchemaDeliveryObject(
4345
+ "table", "codex_window_attributions", None,
4346
+ "043_codex_window_attributions"),
4347
+ SchemaDeliveryObject(
4348
+ "table", "codex_accounting_change_log",
4349
+ "_apply_codex_accounting_change_ledger",
4350
+ "044_codex_accounting_change_ledger"),
4351
+ SchemaDeliveryObject(
4352
+ "trigger", "trg_qws_ledger_del", "_apply_codex_quota_change_ledger",
4353
+ "037_codex_quota_change_ledger"),
4354
+ SchemaDeliveryObject(
4355
+ "trigger", "trg_qws_ledger_ins", "_apply_codex_quota_change_ledger",
4356
+ "037_codex_quota_change_ledger"),
4357
+ SchemaDeliveryObject(
4358
+ "trigger", "trg_qws_ledger_upd", "_apply_codex_quota_change_ledger",
4359
+ "037_codex_quota_change_ledger"),
4360
+ SchemaDeliveryObject(
4361
+ "trigger", "trg_codex_accounting_del",
4362
+ "_apply_codex_accounting_change_ledger",
4363
+ "044_codex_accounting_change_ledger"),
4364
+ SchemaDeliveryObject(
4365
+ "trigger", "trg_codex_accounting_file_del",
4366
+ "_apply_codex_accounting_change_ledger",
4367
+ "044_codex_accounting_change_ledger"),
4368
+ SchemaDeliveryObject(
4369
+ "trigger", "trg_codex_accounting_file_ins",
4370
+ "_apply_codex_accounting_change_ledger",
4371
+ "044_codex_accounting_change_ledger"),
4372
+ SchemaDeliveryObject(
4373
+ "trigger", "trg_codex_accounting_file_upd",
4374
+ "_apply_codex_accounting_change_ledger",
4375
+ "044_codex_accounting_change_ledger"),
4376
+ SchemaDeliveryObject(
4377
+ "trigger", "trg_codex_accounting_ins",
4378
+ "_apply_codex_accounting_change_ledger",
4379
+ "044_codex_accounting_change_ledger"),
4380
+ SchemaDeliveryObject(
4381
+ "trigger", "trg_codex_accounting_thread_del",
4382
+ "_apply_codex_accounting_change_ledger",
4383
+ "044_codex_accounting_change_ledger"),
4384
+ SchemaDeliveryObject(
4385
+ "trigger", "trg_codex_accounting_thread_ins",
4386
+ "_apply_codex_accounting_change_ledger",
4387
+ "044_codex_accounting_change_ledger"),
4388
+ SchemaDeliveryObject(
4389
+ "trigger", "trg_codex_accounting_thread_upd",
4390
+ "_apply_codex_accounting_change_ledger",
4391
+ "044_codex_accounting_change_ledger"),
4392
+ SchemaDeliveryObject(
4393
+ "trigger", "trg_codex_accounting_upd",
4394
+ "_apply_codex_accounting_change_ledger",
4395
+ "044_codex_accounting_change_ledger"),
4396
+ )
4397
+
4398
+
3867
4399
  def _apply_cache_schema(conn: sqlite3.Connection) -> None:
3868
4400
  """Single source of cache.db's schema (cctally-dev#93, spec D4).
3869
4401
 
@@ -4027,9 +4559,11 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
4027
4559
  -- reingest), and migration 019 arms a one-time history backfill.
4028
4560
  --
4029
4561
  -- CRITICAL: this is a PLAIN table with NO dependency on the FTS shape, and
4030
- -- it is created HERE — inside the unconditional executescript, BEFORE the
4031
- -- FTS5 ``legacy_present`` early-return below — so it ALWAYS exists
4032
- -- regardless of FTS topology. (The I-2 title-FTS bug created its vtable
4562
+ -- it is created HERE — inside the version-gated schema executescript,
4563
+ -- BEFORE the FTS5 ``legacy_present`` early-return below — so every
4564
+ -- schema application creates it regardless of FTS topology. Migration
4565
+ -- 019 supplies the head bump for existing stores. (The I-2 title-FTS
4566
+ -- bug created its vtable
4033
4567
  -- AFTER that early-return, so its consumer crashed on a legacy-shape +
4034
4568
  -- both-pending upgrade; the file-touches table must not repeat that class.)
4035
4569
  CREATE TABLE IF NOT EXISTS conversation_file_touches (
@@ -4083,10 +4617,10 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
4083
4617
  ON codex_session_entries(source_path);
4084
4618
 
4085
4619
  -- #294 S1: physical Codex rollout retention. These tables deliberately
4086
- -- live in the unconditional base-schema script, before the legacy-FTS
4087
- -- topology checks below: an existing cache with an old FTS shape must
4088
- -- still gain the S1 tables on every open, just as conversation_file_touches
4089
- -- does above.
4620
+ -- live in the version-gated base-schema script, before the legacy-FTS
4621
+ -- topology checks below: when migration 024 bumps the head, an existing
4622
+ -- cache with an old FTS shape must still gain the S1 tables during that
4623
+ -- schema application, just as conversation_file_touches does above.
4090
4624
  CREATE TABLE IF NOT EXISTS codex_source_roots (
4091
4625
  source_root_key TEXT NOT NULL PRIMARY KEY,
4092
4626
  canonical_root_path TEXT NOT NULL UNIQUE,
@@ -4108,10 +4642,11 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
4108
4642
  -- records NO ROW AT ALL — "undecided" and "decided: no account" are
4109
4643
  -- distinct states and readers must not collapse them.
4110
4644
  --
4111
- -- Both live in the UNCONDITIONAL executescript, BEFORE the FTS5
4645
+ -- Both live in the top-level executescript of the version-gated apply,
4646
+ -- BEFORE the FTS5
4112
4647
  -- `legacy_present` early-return below, because they are plain tables
4113
- -- with no FTS-shape dependency and the ingest path needs them on every
4114
- -- open (the `_apply_cache_schema_legacy_early_return_before_new_table`
4648
+ -- with no FTS-shape dependency and the ingest path needs them whenever
4649
+ -- that apply runs (the `_apply_cache_schema_legacy_early_return_before_new_table`
4115
4650
  -- class). Cache migration 031 exists only to bump the registry head so
4116
4651
  -- an existing install re-runs this schema apply.
4117
4652
  CREATE TABLE IF NOT EXISTS codex_file_incarnations (
@@ -4131,6 +4666,43 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
4131
4666
  CREATE INDEX IF NOT EXISTS idx_codex_file_accounts_root
4132
4667
  ON codex_file_accounts(root_scope);
4133
4668
 
4669
+ -- #500 spec §6.1: the derived index over the operator's durable
4670
+ -- attribution assertions. One row per `codex_window_attribution` op,
4671
+ -- keyed by that op's ordinary content id; a
4672
+ -- `codex_window_attribution_retract` op stamps `retracted_by_op_id` on
4673
+ -- each assertion it names.
4674
+ --
4675
+ -- `account_key` is NOT NULL here, unlike `codex_file_accounts`: this is
4676
+ -- the SUBJECT of an operator assertion, and "the operator asserted no
4677
+ -- account" is not a fact. The builder refuses the `unattributed`
4678
+ -- sentinel outright.
4679
+ --
4680
+ -- `raw_resets_at_utc` stores the witness list as canonical JSON, the
4681
+ -- same spelling the journal payload carries. The group binding is the
4682
+ -- four normalized axes plus an INTERSECTION against those witnesses,
4683
+ -- and `canonical_resets_at_utc` is audit-only — it is
4684
+ -- population-dependent (a later bridging observation can union two
4685
+ -- components and retire the anchor), so it is never matched on.
4686
+ -- Cardinality is dozens of rows, so matching happens in Python.
4687
+ --
4688
+ -- Top-level version-gated executescript for the same reason
4689
+ -- `codex_file_accounts` is: cache migration 043 exists
4690
+ -- to bump the registry head and make an existing install re-run it.
4691
+ CREATE TABLE IF NOT EXISTS codex_window_attributions (
4692
+ op_id TEXT PRIMARY KEY,
4693
+ account_key TEXT NOT NULL,
4694
+ source_root_key TEXT NOT NULL,
4695
+ logical_limit_key TEXT NOT NULL,
4696
+ observed_slot TEXT NOT NULL,
4697
+ window_minutes INTEGER NOT NULL,
4698
+ raw_resets_at_utc TEXT NOT NULL,
4699
+ canonical_resets_at_utc TEXT,
4700
+ asserted_at_utc TEXT NOT NULL,
4701
+ retracted_by_op_id TEXT
4702
+ );
4703
+ CREATE INDEX IF NOT EXISTS idx_codex_window_attributions_root
4704
+ ON codex_window_attributions(source_root_key, window_minutes);
4705
+
4134
4706
  CREATE TABLE IF NOT EXISTS codex_conversation_threads (
4135
4707
  conversation_key TEXT NOT NULL PRIMARY KEY,
4136
4708
  source_root_key TEXT NOT NULL,
@@ -4202,9 +4774,9 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
4202
4774
  ON codex_conversation_events(timestamp_utc);
4203
4775
 
4204
4776
  -- #294 S6: normalized Codex conversation storage. Like the S1 tables
4205
- -- above, these live in the UNCONDITIONAL base-schema script before the
4206
- -- legacy-FTS topology checks below so an existing cache gains them on
4207
- -- every open. The independent Codex FTS layer
4777
+ -- above, these live before the legacy-FTS topology checks in the
4778
+ -- version-gated base schema; the S6 migration head bump makes existing
4779
+ -- caches run that apply once. The independent Codex FTS layer
4208
4780
  -- (_apply_codex_conversation_fts) stands up codex_conversation_fts + its
4209
4781
  -- own triggers separately, BEFORE the Claude legacy-FTS early-return, so
4210
4782
  -- a legacy-shape Claude cache still gets the Codex search index.
@@ -4304,22 +4876,19 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
4304
4876
  )
4305
4877
  # #294 S3: the qualified accounting adapter is bounded by timestamp and
4306
4878
  # joins only through the S1 root-qualified conversation identity. Keep
4307
- # this re-derivable index in the unconditional schema path so an existing
4308
- # cache gains the same scale-safe plan without a data migration.
4879
+ # this re-derivable index in the version-gated schema path. Migration 024's
4880
+ # head bump delivered it to existing caches; future additions need their own
4881
+ # handler-owned delivery path.
4309
4882
  conn.execute(
4310
4883
  "CREATE INDEX IF NOT EXISTS idx_codex_entries_ts_root_conversation "
4311
4884
  "ON codex_session_entries(timestamp_utc, source_root_key, conversation_key)"
4312
4885
  )
4313
4886
  # The per-file alias join in `_codex_conversation_metadata` matches on
4314
- # (source_root_key, source_path). `idx_codex_entries_source_root` cannot
4315
- # serve it: a machine normally has ONE provider root, so a root-only search
4316
- # visits every entry row for every file and the join costs files x entries
4317
- # on every dashboard snapshot build. Re-derivable, so it belongs on the
4318
- # unconditional path with the S3 index above rather than in a migration.
4319
- conn.execute(
4320
- "CREATE INDEX IF NOT EXISTS idx_codex_entries_root_path "
4321
- "ON codex_session_entries(source_root_key, source_path)"
4322
- )
4887
+ # (source_root_key, source_path). #566: this path is NOT unconditional —
4888
+ # `open_cache_db` runs the schema apply only when `user_version` differs
4889
+ # from the migration count so the index also needs cache migration 042,
4890
+ # which delegates to the same helper.
4891
+ _apply_codex_entries_root_path_index(conn)
4323
4892
  # The per-file terminal thread facts seed a later append without rereading
4324
4893
  # the prefix. They are nullable for old cache rows; migration 024 never
4325
4894
  # fabricates these source facts and instead clears/rederives them.
@@ -4419,8 +4988,8 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
4419
4988
  "ON session_entries(mutation_seq, mutation_min_ts)")
4420
4989
  # #279 S3 F3: DB-level idempotency backstop mirroring
4421
4990
  # codex_session_entries' UNIQUE(source_path, line_offset). Guarded and
4422
- # OUTSIDE the top executescript: this function runs on EVERY open BEFORE the
4423
- # migration dispatcher, so a legacy cache.db holding historical
4991
+ # OUTSIDE the top executescript: when the version gate opens, this function
4992
+ # runs BEFORE the migration dispatcher, so a legacy cache.db holding historical
4424
4993
  # physical-key duplicates must tolerate the index being ABSENT until cache
4425
4994
  # migration 020 dedups it — an unguarded CREATE UNIQUE INDEX here would
4426
4995
  # brick every open of such a DB before 020 could ever run. Fresh and clean
@@ -4496,6 +5065,7 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
4496
5065
  _apply_codex_quota_group_index(conn)
4497
5066
  _apply_codex_quota_unresolved_model_index(conn)
4498
5067
  _apply_codex_quota_change_ledger(conn)
5068
+ _apply_codex_accounting_change_ledger(conn)
4499
5069
  conn.execute(
4500
5070
  "CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
4501
5071
  "ON session_files(session_id)"
@@ -4655,6 +5225,7 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
4655
5225
  -- it), so this only clears the now-orphan ledger and keeps
4656
5226
  -- conversations.db transcripts-only.
4657
5227
  DROP TABLE IF EXISTS quota_window_change_log;
5228
+ DROP TABLE IF EXISTS codex_accounting_change_log;
4658
5229
  DROP TABLE IF EXISTS codex_conversation_threads;
4659
5230
  DROP TABLE IF EXISTS codex_source_roots;
4660
5231
  -- #416: the Codex attribution map is a cache.db accounting concern; it
@@ -4662,6 +5233,13 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
4662
5233
  -- rest of the accounting families so conversations.db stays transcripts-only.
4663
5234
  DROP TABLE IF EXISTS codex_file_accounts;
4664
5235
  DROP TABLE IF EXISTS codex_file_incarnations;
5236
+ -- #500: the operator's window-attribution index is the same kind of
5237
+ -- cache.db accounting concern and rides in the same way. Dropping the
5238
+ -- table also drops idx_codex_window_attributions_root, because SQLite
5239
+ -- drops a table's indexes with it. Guarded by
5240
+ -- tests/test_conversations_transcripts_only.py, which derives its
5241
+ -- expectation from COVERAGE_CACHE_FAMILIES rather than restating a list.
5242
+ DROP TABLE IF EXISTS codex_window_attributions;
4665
5243
 
4666
5244
  CREATE TABLE IF NOT EXISTS conversation_source_files (
4667
5245
  path TEXT PRIMARY KEY,
@@ -4769,6 +5347,72 @@ def _apply_codex_find_projection_schema(conn: sqlite3.Connection) -> None:
4769
5347
  _set_cache_meta(conn, "codex_find_projection_generation", "0")
4770
5348
 
4771
5349
 
5350
+ #: Every surviving table, view, trigger and index declared by
5351
+ #: ``_apply_conversations_schema``, and how it reaches an already-current
5352
+ #: conversations.db (#580). Most transcript objects predate this registry and
5353
+ #: are therefore frozen baseline records. In particular, the three account
5354
+ #: indexes added by ``b3f859fec`` do NOT claim a migration: their DDL was not
5355
+ #: owned by the migration handler and only reached existing stores because a
5356
+ #: later registry-head bump happened to re-run the schema apply. The Codex find
5357
+ #: projection is the one audited family with handler-owned delivery DDL.
5358
+ CONVERSATIONS_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
5359
+ # ── frozen baseline: audited objects without handler-owned delivery DDL ──
5360
+ SchemaDeliveryObject(
5361
+ "index", "idx_codex_conv_messages_account_conversation", None, None),
5362
+ SchemaDeliveryObject("index", "idx_codex_conv_msgs_conversation", None, None),
5363
+ SchemaDeliveryObject("index", "idx_codex_conv_msgs_source", None, None),
5364
+ SchemaDeliveryObject("index", "idx_codex_conv_rollups_recent", None, None),
5365
+ SchemaDeliveryObject("index", "idx_codex_conv_touches_source", None, None),
5366
+ SchemaDeliveryObject(
5367
+ "index", "idx_codex_events_account_conversation", None, None),
5368
+ SchemaDeliveryObject("index", "idx_codex_events_conversation", None, None),
5369
+ SchemaDeliveryObject("index", "idx_codex_events_timestamp", None, None),
5370
+ SchemaDeliveryObject("index", "idx_conv_messages_account_session", None, None),
5371
+ SchemaDeliveryObject("index", "idx_conv_session_ts", None, None),
5372
+ SchemaDeliveryObject("index", "idx_conv_session_uuid", None, None),
5373
+ SchemaDeliveryObject("index", "idx_conv_sessions_recent", None, None),
5374
+ SchemaDeliveryObject("index", "idx_conv_source", None, None),
5375
+ SchemaDeliveryObject("index", "idx_conv_turnkey", None, None),
5376
+ SchemaDeliveryObject("index", "idx_conversation_messages_cwd", None, None),
5377
+ SchemaDeliveryObject(
5378
+ "index", "idx_conversation_messages_model_session", None, None),
5379
+ SchemaDeliveryObject("index", "idx_file_touches_path", None, None),
5380
+ SchemaDeliveryObject("table", "cache_meta", None, None),
5381
+ SchemaDeliveryObject("table", "codex_conversation_events", None, None),
5382
+ SchemaDeliveryObject("table", "codex_conversation_file_touches", None, None),
5383
+ SchemaDeliveryObject("table", "codex_conversation_fts", None, None),
5384
+ SchemaDeliveryObject("table", "codex_conversation_messages", None, None),
5385
+ SchemaDeliveryObject("table", "codex_conversation_rollups", None, None),
5386
+ SchemaDeliveryObject("table", "codex_conversation_source_files", None, None),
5387
+ SchemaDeliveryObject("table", "conversation_ai_titles", None, None),
5388
+ SchemaDeliveryObject("table", "conversation_file_touches", None, None),
5389
+ SchemaDeliveryObject("table", "conversation_fts", None, None),
5390
+ SchemaDeliveryObject("table", "conversation_messages", None, None),
5391
+ SchemaDeliveryObject("table", "conversation_sessions", None, None),
5392
+ SchemaDeliveryObject("table", "conversation_source_files", None, None),
5393
+ SchemaDeliveryObject("table", "conversation_title_fts", None, None),
5394
+ SchemaDeliveryObject("trigger", "codex_conv_fts_ad", None, None),
5395
+ SchemaDeliveryObject("trigger", "codex_conv_fts_ai", None, None),
5396
+ SchemaDeliveryObject("trigger", "codex_conv_fts_au", None, None),
5397
+ SchemaDeliveryObject("trigger", "conv_fts_ad", None, None),
5398
+ SchemaDeliveryObject("trigger", "conv_fts_ai", None, None),
5399
+ SchemaDeliveryObject("trigger", "conv_fts_au", None, None),
5400
+ SchemaDeliveryObject("trigger", "conv_title_fts_ad", None, None),
5401
+ SchemaDeliveryObject("trigger", "conv_title_fts_ai", None, None),
5402
+ SchemaDeliveryObject("trigger", "conv_title_fts_au", None, None),
5403
+ # ── post-baseline: migration 004 owns the shared projection helper ──
5404
+ SchemaDeliveryObject(
5405
+ "index", "idx_codex_find_projection_conversation_order",
5406
+ "_apply_codex_find_projection_schema", "004_codex_find_projection"),
5407
+ SchemaDeliveryObject(
5408
+ "table", "codex_find_projection",
5409
+ "_apply_codex_find_projection_schema", "004_codex_find_projection"),
5410
+ SchemaDeliveryObject(
5411
+ "trigger", "codex_find_projection_message_ad",
5412
+ "_apply_codex_find_projection_schema", "004_codex_find_projection"),
5413
+ )
5414
+
5415
+
4772
5416
  def _fts5_available(conn: sqlite3.Connection) -> bool:
4773
5417
  """True if this sqlite build can create an FTS5 table. Cheap probe on a
4774
5418
  temp table that is created then dropped. Hidden test seam: tests monkeypatch
@@ -5644,6 +6288,14 @@ def _eagerly_apply_cache_migrations_under_writer_lock() -> None:
5644
6288
  pass
5645
6289
  conn = sqlite3.connect(cache_db_path)
5646
6290
  try:
6291
+ # #566: the refusal must precede the DDL it exists to prevent. Both
6292
+ # cache-open paths used to reach the dispatcher's #142 guard only after
6293
+ # the schema apply had already run, so a dev-checkout binary pointed at
6294
+ # the real prod dir modified the production schema and only then
6295
+ # refused. `journal_mode` is itself a persistent write, so this comes
6296
+ # before it.
6297
+ _refuse_prod_migration_before_schema_write(
6298
+ conn, _CACHE_MIGRATIONS, "cache.db")
5647
6299
  conn.execute("PRAGMA journal_mode=WAL")
5648
6300
  conn.execute("PRAGMA busy_timeout=5000")
5649
6301
  # Apply the shared cache.db schema (cctally-dev#93, D4). This is the
@@ -6338,8 +6990,9 @@ def _011_conversation_promote_command_args(conn: sqlite3.Connection) -> None:
6338
6990
  @cache_migration("012_create_conversation_ai_titles")
6339
6991
  def _012_create_conversation_ai_titles(conn: sqlite3.Connection) -> None:
6340
6992
  """Flag-only arm for #193. The conversation_ai_titles table itself is created
6341
- by _apply_cache_schema (runs on every open, fresh + existing installs); this
6342
- migration sets ``ai_titles_backfill_pending`` so sync_cache walks all history
6993
+ by _apply_cache_schema when the version gate opens; this migration's head
6994
+ bump makes existing installs take that path. This migration sets
6995
+ ``ai_titles_backfill_pending`` so sync_cache walks all history
6343
6996
  once via backfill_ai_titles under the cache.db.lock flock. No data work here
6344
6997
  -> the dispatcher's central stamp (#140) marks a complete handler; a fresh
6345
6998
  install stamps WITHOUT a populated history (its incremental walk fills the
@@ -6351,7 +7004,8 @@ def _012_create_conversation_ai_titles(conn: sqlite3.Connection) -> None:
6351
7004
  @cache_migration("013_create_conversation_sessions")
6352
7005
  def _013_create_conversation_sessions(conn: sqlite3.Connection) -> None:
6353
7006
  """Flag-only arm for the conversation_sessions browse-rail rollup. The table
6354
- is created by _apply_cache_schema (every open); this sets
7007
+ is created by _apply_cache_schema when the version gate opens; this
7008
+ migration's head bump makes existing installs take that path. It sets
6355
7009
  conversation_sessions_backfill_pending so sync_cache does the one-time full
6356
7010
  GROUP BY recompute under the cache.db.lock flock. No data work here — the
6357
7011
  dispatcher's central stamp (#140) marks a complete handler; a fresh install
@@ -6545,7 +7199,8 @@ def _018_create_conversation_title_fts(conn: sqlite3.Connection) -> None:
6545
7199
 
6546
7200
  Flag-only arm. The ``conversation_title_fts`` virtual table + its
6547
7201
  conv_title_fts_ai/ad/au sync triggers are created by ``_apply_cache_schema``
6548
- (runs on every open, fresh + existing installs) inside the SAME FTS5-available
7202
+ when the version gate opens (this migration bumps the head for existing
7203
+ installs) inside the SAME FTS5-available
6549
7204
  envelope as the message FTS (P1-6) — so on a no-FTS5 build the table+triggers
6550
7205
  are simply absent and a title upsert never rolls back the ingest. This handler
6551
7206
  does NO DDL (mirrors 012's flag-only pattern): it just arms the DISTINCT
@@ -6576,7 +7231,8 @@ def _019_create_conversation_file_touches(conn: sqlite3.Connection) -> None:
6576
7231
 
6577
7232
  Flag-only arm (mirrors 018's pattern). The ``conversation_file_touches`` table
6578
7233
  + its ``COLLATE NOCASE`` path index (``idx_file_touches_path``) are created by
6579
- ``_apply_cache_schema`` (runs on every open, fresh + existing installs) — and
7234
+ ``_apply_cache_schema`` when the version gate opens (this migration bumps
7235
+ the head for existing installs) — and
6580
7236
  CRITICALLY before the FTS5 ``legacy_present`` early-return, since the table is
6581
7237
  plain and has NO dependency on the FTS shape (so a legacy-shape upgrade still
6582
7238
  gets it). The NOCASE collation is what lets the kind=files PREFIX search ride
@@ -7169,8 +7825,9 @@ def _031_codex_file_account_map(conn: sqlite3.Connection) -> None:
7169
7825
  existing install.
7170
7826
 
7171
7827
  The two tables (``codex_file_incarnations``, ``codex_file_accounts``) are
7172
- created by ``_apply_cache_schema`` in its UNCONDITIONAL executescript the
7173
- repo's table-addition rule, and specifically BEFORE the FTS5
7828
+ created by the top-level executescript in the version-gated
7829
+ ``_apply_cache_schema`` — the repo's table-addition rule, and specifically
7830
+ BEFORE the FTS5
7174
7831
  ``legacy_present`` early-return so a legacy-shape cache still receives them.
7175
7832
  This migration exists because that schema apply is VERSION-GATED: a
7176
7833
  steady-state open compares ``PRAGMA user_version`` against
@@ -7682,6 +8339,125 @@ def _041_codex_quota_unresolved_model_index(conn: sqlite3.Connection) -> None:
7682
8339
  conn.commit()
7683
8340
 
7684
8341
 
8342
+ @cache_migration("042_codex_entries_root_path_index")
8343
+ def _042_codex_entries_root_path_index(conn: sqlite3.Connection) -> None:
8344
+ """#566: deliver the per-file alias join index to already-current stores.
8345
+
8346
+ ``d1f14fad3`` added this index to ``_apply_cache_schema`` believing that
8347
+ path was unconditional. ``open_cache_db`` runs the schema apply only under
8348
+ ``if not schema_current:``, and ``schema_current`` is true when
8349
+ ``user_version`` equals ``len(_CACHE_MIGRATIONS)``. Every existing install
8350
+ was already at head, so the index reached new stores only, and a real
8351
+ install measured a ~90s snapshot build and a 167-184s publish period while
8352
+ running the release that contained the fix.
8353
+
8354
+ Same version-gate reason as 036/037/038/040/041: registering here is what
8355
+ makes an already-current install pick it up.
8356
+
8357
+ Re-running is a no-op (``IF NOT EXISTS``). NO self-stamp — the dispatcher
8358
+ central-stamps on a clean return (#140).
8359
+ """
8360
+ _apply_codex_entries_root_path_index(conn)
8361
+ conn.commit()
8362
+
8363
+
8364
+ @cache_migration("043_codex_window_attributions")
8365
+ def _043_codex_window_attributions(conn: sqlite3.Connection) -> None:
8366
+ """Deliver the #500 operator-attribution index to already-current stores.
8367
+
8368
+ Spec:
8369
+ ``docs/superpowers/specs/2026-08-14-500-codex-window-attribution-design.md``
8370
+ §6.1.
8371
+
8372
+ ``codex_window_attributions`` lives in the top-level executescript of the
8373
+ version-gated ``_apply_cache_schema``, and ``open_cache_db`` runs that
8374
+ script only when ``schema_current(conn, store)`` is false — i.e. when
8375
+ ``user_version`` differs from ``len(_CACHE_MIGRATIONS)``. Every existing
8376
+ install is already at head, so the DDL alone would reach new stores only.
8377
+ Registering here is what bumps the head and makes an existing install
8378
+ re-run the schema apply, exactly as 031/036/037/038/040/041/042 do.
8379
+
8380
+ The handler then materializes the table from the journal, which is its only
8381
+ source. That is a from-zero ADDITIVE replay rather than the authoritative
8382
+ clear-then-replay ``cache-sync --rebuild`` uses: the table has just been
8383
+ created empty, so an additive pass materializes it completely, and a
8384
+ clear here would delete a covered family while a coverage certificate may
8385
+ still stand — the invariant
8386
+ ``_cctally_journal._assert_coverage_already_invalidated`` exists to refuse.
8387
+
8388
+ Idempotent by construction: assertions insert on the op-id primary key with
8389
+ ``OR IGNORE`` and a retraction stamps only an assertion that is not already
8390
+ retracted, so a re-run over its own output writes nothing but the cursor.
8391
+ NO self-stamp — the dispatcher central-stamps on a clean return (#140).
8392
+
8393
+ Takes the Codex provider flock like handlers 024-027 and 034: this writes a
8394
+ Codex-derived table, so a mid-walk ``sync_codex_cache`` must not interleave.
8395
+ On contention it DEFERS (``MigrationGateNotMet``) before touching any data,
8396
+ which is free here because that same sync rehydrates this table at its own
8397
+ start anyway.
8398
+ """
8399
+ import _cctally_cache as cache_mod
8400
+
8401
+ # Defensive re-assert, not the primary creation path — the same shape
8402
+ # migration 031 carries and for the same reason: it keeps the handler
8403
+ # self-contained if `_apply_cache_schema`'s ordering ever drifts, and it is
8404
+ # what the per-migration golden exercises, whose `pre.sqlite` is a genuine
8405
+ # 042-head install that predates the table. Re-running is a no-op.
8406
+ conn.executescript(
8407
+ """
8408
+ CREATE TABLE IF NOT EXISTS codex_window_attributions (
8409
+ op_id TEXT PRIMARY KEY,
8410
+ account_key TEXT NOT NULL,
8411
+ source_root_key TEXT NOT NULL,
8412
+ logical_limit_key TEXT NOT NULL,
8413
+ observed_slot TEXT NOT NULL,
8414
+ window_minutes INTEGER NOT NULL,
8415
+ raw_resets_at_utc TEXT NOT NULL,
8416
+ canonical_resets_at_utc TEXT,
8417
+ asserted_at_utc TEXT NOT NULL,
8418
+ retracted_by_op_id TEXT
8419
+ );
8420
+ CREATE INDEX IF NOT EXISTS idx_codex_window_attributions_root
8421
+ ON codex_window_attributions(source_root_key, window_minutes);
8422
+ """
8423
+ )
8424
+ held = _acquire_cache_db_codex_provider_flock(
8425
+ conn, migration="043 window attributions")
8426
+ try:
8427
+ conn.execute("BEGIN IMMEDIATE")
8428
+ try:
8429
+ _applied, skipped = cache_mod.rehydrate_codex_window_attributions(
8430
+ conn, authoritative=False)
8431
+ conn.commit()
8432
+ except Exception:
8433
+ conn.rollback()
8434
+ raise
8435
+ # AFTER the commit, the rule `_report_file_account_conflicts` states:
8436
+ # the `except` above rolls back, so a line printed before it would
8437
+ # describe a skip on work that was undone (review finding F2).
8438
+ import _cctally_journal as journal_mod
8439
+ journal_mod._report_window_attribution_skips(skipped)
8440
+ finally:
8441
+ _release_cache_db_writer_flocks(held)
8442
+
8443
+
8444
+ @cache_migration("044_codex_accounting_change_ledger")
8445
+ def _044_codex_accounting_change_ledger(conn: sqlite3.Connection) -> None:
8446
+ """Deliver #582's dirty-path ledger to already-current cache stores.
8447
+
8448
+ The steady-state schema apply is version-gated, so adding the table and
8449
+ triggers there alone would leave every 043-head installation without the
8450
+ mutation stream the dashboard requires. The shared helper keeps fresh and
8451
+ upgraded stores byte-identical. Existing accounting rows need no backfill:
8452
+ the first dashboard build is deliberately cold and subsequent mutations
8453
+ advance the new sequence.
8454
+
8455
+ The DDL is idempotent and the dispatcher owns the applied marker.
8456
+ """
8457
+ _apply_codex_accounting_change_ledger(conn)
8458
+ conn.commit()
8459
+
8460
+
7685
8461
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
7686
8462
 
7687
8463
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")