graphite-code 0.3.0__py3-none-any.whl

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 (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,977 @@
1
+ """Isolated SQLite authority storage for provider lifecycle observations."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import secrets
8
+ import sqlite3
9
+ import stat
10
+ from contextlib import closing, contextmanager, suppress
11
+ from collections.abc import Iterator
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Final
15
+
16
+ from .lifecycle import (
17
+ LifecycleReasonCode,
18
+ ProviderLifecycleEvent,
19
+ ProviderLifecycleState,
20
+ ProviderRuntimeIdentity,
21
+ LifecycleProviderId,
22
+ RuntimeKind,
23
+ )
24
+ from .storage import (
25
+ BUSY_TIMEOUT_MS,
26
+ _secure_file,
27
+ _secure_repository_directory,
28
+ _is_reparse_point,
29
+ _selected_root,
30
+ _validate_database_file,
31
+ )
32
+
33
+ LIFECYCLE_SCHEMA_VERSION: Final = "2"
34
+ MAX_EVENT_PAGE_SIZE: Final = 100
35
+ MAX_INVALIDATION_TARGETS: Final = 128
36
+
37
+ _PROVIDER_VALUES = "'claude-code','codex','ollama','openrouter','zai'"
38
+ _RUNTIME_VALUES = "'local-cli','local-http','remote-https'"
39
+ _STATE_VALUES = (
40
+ "'discovered','compatible','verification_required','active','incompatible','unavailable'"
41
+ )
42
+ _HEX_64_GLOB = "length({column}) = 64 AND {column} NOT GLOB '*[^0-9a-f]*'"
43
+
44
+
45
+ class LifecycleStorageError(RuntimeError):
46
+ """A stable, path-free lifecycle persistence failure."""
47
+
48
+ def __init__(self, code: str) -> None:
49
+ self.code = code
50
+ super().__init__(code)
51
+
52
+
53
+ def _translate_database_error(exc: sqlite3.Error) -> LifecycleStorageError:
54
+ message = str(exc).casefold()
55
+ if "locked" in message or "busy" in message:
56
+ return LifecycleStorageError("lifecycle_storage_locked")
57
+ if (
58
+ "database disk image is malformed" in message
59
+ or "not a database" in message
60
+ or "file is encrypted" in message
61
+ ):
62
+ return LifecycleStorageError("lifecycle_storage_corrupt")
63
+ return LifecycleStorageError("lifecycle_storage_unavailable")
64
+
65
+
66
+ def _digest(value: object, code: str) -> str:
67
+ if (
68
+ not isinstance(value, str)
69
+ or len(value) != 64
70
+ or any(character not in "0123456789abcdef" for character in value)
71
+ ):
72
+ raise ValueError(code)
73
+ return value
74
+
75
+
76
+ def _identifier(value: object, code: str) -> str:
77
+ if (
78
+ not isinstance(value, str)
79
+ or not value
80
+ or len(value) > 256
81
+ or any(not (character.isascii() and (character.isalnum() or character in "._:-")) for character in value)
82
+ ):
83
+ raise ValueError(code)
84
+ return value
85
+
86
+
87
+ def _timestamp(value: object, code: str) -> int:
88
+ if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 10**12:
89
+ raise ValueError(code)
90
+ return value
91
+
92
+
93
+ def _canonical_json(value: dict[str, object]) -> str:
94
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
95
+
96
+
97
+ def _payload_hash(payload: str) -> str:
98
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
99
+
100
+
101
+ def _identity_from_json(payload: str) -> ProviderRuntimeIdentity:
102
+ try:
103
+ value = json.loads(payload)
104
+ if not isinstance(value, dict) or set(value) != set(ProviderRuntimeIdentity._public_fields):
105
+ raise ValueError
106
+ return ProviderRuntimeIdentity(
107
+ provider=value["provider"],
108
+ runtime_kind=value["runtime_kind"],
109
+ version=value["version"],
110
+ runtime_digest=value["runtime_digest"],
111
+ model_identity_digest=value["model_identity_digest"],
112
+ routing_policy_digest=value["routing_policy_digest"],
113
+ capabilities=tuple(value["capabilities"]),
114
+ policy_version=value["policy_version"],
115
+ observed_at=value["observed_at"],
116
+ )
117
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
118
+ raise LifecycleStorageError("lifecycle_storage_corrupt") from exc
119
+
120
+
121
+ def _event_from_json(payload: str) -> ProviderLifecycleEvent:
122
+ try:
123
+ value = json.loads(payload)
124
+ if not isinstance(value, dict) or set(value) != set(ProviderLifecycleEvent._public_fields):
125
+ raise ValueError
126
+ return ProviderLifecycleEvent(**value)
127
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
128
+ raise LifecycleStorageError("lifecycle_storage_corrupt") from exc
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class CurrentLifecycleObservation:
133
+ boundary_digest: str
134
+ provider: LifecycleProviderId
135
+ runtime_kind: RuntimeKind
136
+ identity: ProviderRuntimeIdentity | None
137
+ state: ProviderLifecycleState
138
+ policy_version: str
139
+ observed_at: int
140
+ updated_at: int
141
+
142
+
143
+ @dataclass(frozen=True, slots=True)
144
+ class LifecycleInvalidationRecord:
145
+ invalidation_id: str
146
+ boundary_digest: str
147
+ previous_identity_digest: str
148
+ current_identity_digest: str
149
+ target_kind: str
150
+ target_id: str
151
+ reason: LifecycleReasonCode
152
+ invalidated_at: int
153
+
154
+
155
+ _SCHEMA = (
156
+ "CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
157
+ f"""CREATE TABLE IF NOT EXISTS current_observations (
158
+ boundary_digest TEXT PRIMARY KEY CHECK({_HEX_64_GLOB.format(column='boundary_digest')}),
159
+ provider TEXT NOT NULL CHECK(provider IN ({_PROVIDER_VALUES})),
160
+ runtime_kind TEXT NOT NULL CHECK(runtime_kind IN ({_RUNTIME_VALUES})),
161
+ identity_digest TEXT CHECK(
162
+ identity_digest IS NULL OR ({_HEX_64_GLOB.format(column='identity_digest')})
163
+ ),
164
+ state TEXT NOT NULL CHECK(state IN ({_STATE_VALUES})),
165
+ policy_version TEXT NOT NULL,
166
+ identity_json TEXT CHECK(identity_json IS NULL OR length(identity_json) <= 8192),
167
+ observed_at INTEGER NOT NULL CHECK(observed_at >= 0),
168
+ updated_at INTEGER NOT NULL CHECK(updated_at >= observed_at),
169
+ CHECK(
170
+ (provider IN ('claude-code','codex') AND runtime_kind='local-cli')
171
+ OR (provider='ollama' AND runtime_kind='local-http')
172
+ OR (provider IN ('openrouter','zai') AND runtime_kind='remote-https')
173
+ ),
174
+ CHECK(state='unavailable' OR (identity_digest IS NOT NULL AND identity_json IS NOT NULL))
175
+ )""",
176
+ f"""CREATE TABLE IF NOT EXISTS lifecycle_events (
177
+ event_id TEXT PRIMARY KEY,
178
+ boundary_digest TEXT NOT NULL CHECK({_HEX_64_GLOB.format(column='boundary_digest')}),
179
+ provider TEXT NOT NULL CHECK(provider IN ({_PROVIDER_VALUES})),
180
+ runtime_kind TEXT NOT NULL CHECK(runtime_kind IN ({_RUNTIME_VALUES})),
181
+ previous_identity_digest TEXT CHECK(
182
+ previous_identity_digest IS NULL OR ({_HEX_64_GLOB.format(column='previous_identity_digest')})
183
+ ),
184
+ current_identity_digest TEXT CHECK(
185
+ current_identity_digest IS NULL OR ({_HEX_64_GLOB.format(column='current_identity_digest')})
186
+ ),
187
+ previous_state TEXT CHECK(previous_state IS NULL OR previous_state IN ({_STATE_VALUES})),
188
+ current_state TEXT NOT NULL CHECK(current_state IN ({_STATE_VALUES})),
189
+ reason TEXT NOT NULL,
190
+ policy_version TEXT NOT NULL,
191
+ occurred_at INTEGER NOT NULL CHECK(occurred_at >= 0),
192
+ payload_json TEXT NOT NULL CHECK(length(payload_json) <= 8192),
193
+ payload_hash TEXT NOT NULL CHECK({_HEX_64_GLOB.format(column='payload_hash')}),
194
+ CHECK(
195
+ (provider IN ('claude-code','codex') AND runtime_kind='local-cli')
196
+ OR (provider='ollama' AND runtime_kind='local-http')
197
+ OR (provider IN ('openrouter','zai') AND runtime_kind='remote-https')
198
+ )
199
+ )""",
200
+ "CREATE INDEX IF NOT EXISTS lifecycle_events_boundary_time_idx ON lifecycle_events(boundary_digest, occurred_at, event_id)",
201
+ f"""CREATE TABLE IF NOT EXISTS authority_invalidations (
202
+ invalidation_id TEXT PRIMARY KEY CHECK({_HEX_64_GLOB.format(column='invalidation_id')}),
203
+ boundary_digest TEXT NOT NULL CHECK({_HEX_64_GLOB.format(column='boundary_digest')}),
204
+ previous_identity_digest TEXT NOT NULL CHECK({_HEX_64_GLOB.format(column='previous_identity_digest')}),
205
+ current_identity_digest TEXT NOT NULL CHECK({_HEX_64_GLOB.format(column='current_identity_digest')}),
206
+ target_kind TEXT NOT NULL CHECK(target_kind IN ('capability_snapshot','approval')),
207
+ target_id TEXT NOT NULL,
208
+ reason TEXT NOT NULL,
209
+ invalidated_at INTEGER NOT NULL CHECK(invalidated_at >= 0)
210
+ )""",
211
+ "CREATE INDEX IF NOT EXISTS authority_invalidations_boundary_time_idx ON authority_invalidations(boundary_digest, invalidated_at, invalidation_id)",
212
+ """CREATE TRIGGER IF NOT EXISTS lifecycle_event_update_guard
213
+ BEFORE UPDATE ON lifecycle_events BEGIN
214
+ SELECT RAISE(ABORT, 'lifecycle_event_immutable');
215
+ END""",
216
+ """CREATE TRIGGER IF NOT EXISTS lifecycle_event_delete_guard
217
+ BEFORE DELETE ON lifecycle_events BEGIN
218
+ SELECT RAISE(ABORT, 'lifecycle_event_immutable');
219
+ END""",
220
+ """CREATE TRIGGER IF NOT EXISTS lifecycle_invalidation_update_guard
221
+ BEFORE UPDATE ON authority_invalidations BEGIN
222
+ SELECT RAISE(ABORT, 'lifecycle_invalidation_immutable');
223
+ END""",
224
+ """CREATE TRIGGER IF NOT EXISTS lifecycle_invalidation_delete_guard
225
+ BEFORE DELETE ON authority_invalidations BEGIN
226
+ SELECT RAISE(ABORT, 'lifecycle_invalidation_immutable');
227
+ END""",
228
+ )
229
+
230
+
231
+ class LifecycleStore:
232
+ """Repository-scoped provider lifecycle authority and sanitized evidence."""
233
+
234
+ def __init__(self, repository_root: Path, *, busy_timeout_ms: int = BUSY_TIMEOUT_MS) -> None:
235
+ self.root = _selected_root(repository_root)
236
+ if isinstance(busy_timeout_ms, bool) or not 100 <= busy_timeout_ms <= 30_000:
237
+ raise ValueError("busy_timeout_invalid")
238
+ self.busy_timeout_ms = busy_timeout_ms
239
+ self.path = self.root / ".graphite" / "routing" / "provider-lifecycle.sqlite3"
240
+
241
+ @property
242
+ def backup_path(self) -> Path:
243
+ return self.path.parent / "backups" / "provider-lifecycle-schema-v1.sqlite3"
244
+
245
+ @property
246
+ def backup_marker_path(self) -> Path:
247
+ return self.path.parent / "backups" / "provider-lifecycle-schema-v1.sha256.json"
248
+
249
+ def _connect(self) -> sqlite3.Connection:
250
+ # The handle is opened first and configured second, so a failure in the
251
+ # configuration leaves a live handle that the caller never receives --
252
+ # no `try/finally` upstream can reach it, and only the collector ever
253
+ # frees it. `PRAGMA journal_mode = WAL` is the realistic trigger: it
254
+ # takes a lock, so it is what answers "database is locked" when another
255
+ # connection holds one.
256
+ #
257
+ # This is the `with connection:` defect one level lower. There the
258
+ # transaction ended and the handle stayed open; here the error path drops
259
+ # the reference before anyone can own it. Both leave Windows unable to
260
+ # unlink the database, and both do it on the RECOVERY path.
261
+ connection: sqlite3.Connection | None = None
262
+ try:
263
+ connection = sqlite3.connect(
264
+ self.path,
265
+ timeout=self.busy_timeout_ms / 1_000,
266
+ isolation_level=None,
267
+ )
268
+ connection.row_factory = sqlite3.Row
269
+ connection.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}")
270
+ connection.execute("PRAGMA foreign_keys = ON")
271
+ connection.execute("PRAGMA journal_mode = WAL")
272
+ return connection
273
+ except sqlite3.Error as exc:
274
+ if connection is not None:
275
+ # Suppressed, not swallowed: a close that also fails must not
276
+ # replace the diagnosis the caller is about to receive.
277
+ with suppress(sqlite3.Error):
278
+ connection.close()
279
+ raise _translate_database_error(exc) from exc
280
+
281
+ @contextmanager
282
+ def _connection(self) -> Iterator[sqlite3.Connection]:
283
+ """End the transaction like `with connection:` does, then actually CLOSE it.
284
+
285
+ `sqlite3.Connection.__exit__` commits or rolls back and leaves the handle
286
+ OPEN -- a long-standing trap in that API. `with self._connection() as c:`
287
+ therefore released nothing, and 51 sites across this package used it.
288
+ Measured: 934 `ResourceWarning: unclosed database` in one suite run.
289
+
290
+ Refcounting hides it: the local dies when the method returns and CPython
291
+ finalizes immediately, which is why the suite was green. It stops hiding
292
+ the moment anything else holds a frame alive -- a traceback, a debugger,
293
+ a coverage tracer. Under `pytest --cov` the v3->v4 and v4->v5 rollback
294
+ drills failed with `PermissionError: [WinError 32]`: Windows will not
295
+ unlink a file with an open handle, and rollback REPLACES the live
296
+ database. The recovery path was depending on collector timing.
297
+
298
+ The methods that manage their own transaction keep using `_connect` with
299
+ an explicit `try/finally: connection.close()` -- they were already
300
+ correct, and wrapping them here would add a second commit they did not
301
+ ask for.
302
+ """
303
+ connection = self._connect()
304
+ try:
305
+ with connection:
306
+ yield connection
307
+ finally:
308
+ connection.close()
309
+
310
+ def _connect_readonly(self) -> sqlite3.Connection:
311
+ """Open existing lifecycle authority without creating or mutating repository state."""
312
+ current = self.root
313
+ try:
314
+ for part in self.path.parent.relative_to(self.root).parts:
315
+ current /= part
316
+ metadata = current.lstat()
317
+ if (
318
+ not stat.S_ISDIR(metadata.st_mode)
319
+ or stat.S_ISLNK(metadata.st_mode)
320
+ or _is_reparse_point(metadata)
321
+ ):
322
+ raise LifecycleStorageError("lifecycle_storage_path_invalid")
323
+ metadata = self.path.lstat()
324
+ if (
325
+ not stat.S_ISREG(metadata.st_mode)
326
+ or stat.S_ISLNK(metadata.st_mode)
327
+ or _is_reparse_point(metadata)
328
+ ):
329
+ raise LifecycleStorageError("lifecycle_storage_path_invalid")
330
+ except FileNotFoundError as exc:
331
+ raise LifecycleStorageError("lifecycle_storage_missing") from exc
332
+ except LifecycleStorageError:
333
+ raise
334
+ except OSError as exc:
335
+ raise LifecycleStorageError("lifecycle_storage_unavailable") from exc
336
+ connection: sqlite3.Connection | None = None
337
+ try:
338
+ connection = sqlite3.connect(
339
+ f"{self.path.as_uri()}?mode=ro",
340
+ uri=True,
341
+ timeout=self.busy_timeout_ms / 1_000,
342
+ isolation_level=None,
343
+ )
344
+ connection.row_factory = sqlite3.Row
345
+ connection.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}")
346
+ connection.execute("PRAGMA foreign_keys = ON")
347
+ connection.execute("PRAGMA query_only = ON")
348
+ version = connection.execute(
349
+ "SELECT value FROM schema_meta WHERE key='schema_version'"
350
+ ).fetchone()
351
+ if version is None or version[0] != LIFECYCLE_SCHEMA_VERSION:
352
+ raise LifecycleStorageError("lifecycle_schema_unsupported")
353
+ self._validate_schema(connection)
354
+ self._validate_integrity(connection)
355
+ return connection
356
+ except LifecycleStorageError:
357
+ if connection is not None:
358
+ connection.close()
359
+ raise
360
+ except sqlite3.Error as exc:
361
+ if connection is not None:
362
+ connection.close()
363
+ raise _translate_database_error(exc) from exc
364
+
365
+ def initialize(self) -> None:
366
+ _secure_repository_directory(self.root, self.path.parent)
367
+ _validate_database_file(self.path)
368
+ connection: sqlite3.Connection | None = None
369
+ try:
370
+ connection = self._connect()
371
+ connection.execute("BEGIN IMMEDIATE")
372
+ connection.execute(_SCHEMA[0])
373
+ # Rebuild the provider-CHECK tables in place before the version gate
374
+ # reads the schema version, so an existing v1 store is at "2" by the
375
+ # time the gate and validators run.
376
+ self._migrate_v1_to_v2_zai(connection)
377
+ version = connection.execute(
378
+ "SELECT value FROM schema_meta WHERE key='schema_version'"
379
+ ).fetchone()
380
+ if version is not None and version[0] != LIFECYCLE_SCHEMA_VERSION:
381
+ raise LifecycleStorageError("lifecycle_schema_unsupported")
382
+ if version is not None:
383
+ self._validate_schema(connection)
384
+ for statement in _SCHEMA[1:]:
385
+ connection.execute(statement)
386
+ connection.execute(
387
+ """INSERT INTO schema_meta(key,value) VALUES('schema_version',?)
388
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value""",
389
+ (LIFECYCLE_SCHEMA_VERSION,),
390
+ )
391
+ connection.commit()
392
+ self._validate_integrity(connection)
393
+ except LifecycleStorageError:
394
+ if connection is not None and connection.in_transaction:
395
+ connection.rollback()
396
+ raise
397
+ except sqlite3.Error as exc:
398
+ if connection is not None and connection.in_transaction:
399
+ connection.rollback()
400
+ raise _translate_database_error(exc) from exc
401
+ finally:
402
+ if connection is not None:
403
+ connection.close()
404
+ _validate_database_file(self.path)
405
+ _secure_file(self.path)
406
+
407
+ @staticmethod
408
+ def _migrate_v1_to_v2_zai(connection: sqlite3.Connection) -> None:
409
+ """Rebuild the provider-CHECK tables in place so an existing v1 store admits zai.
410
+
411
+ Runs on the caller's connection INSIDE ``initialize()``'s open
412
+ ``BEGIN IMMEDIATE`` transaction and before the schema-version gate. There
413
+ is no second connection (which would deadlock on the write lock), no
414
+ ``foreign_keys`` toggle (this store has no foreign keys), and no
415
+ independent commit/rollback: ``initialize()``'s existing commit finalizes
416
+ the rebuild and its ``except`` rolls the whole thing back, which also
417
+ provides the quarantine-on-mismatch path for free.
418
+ """
419
+ row = connection.execute(
420
+ "SELECT value FROM schema_meta WHERE key='schema_version'"
421
+ ).fetchone()
422
+ if row is None or row[0] != "1":
423
+ # Fresh installs (None) and already-migrated stores ("2") get the
424
+ # widened CHECK straight from _SCHEMA; unknown versions fall through
425
+ # to initialize()'s version gate.
426
+ return
427
+ rebuilds = (
428
+ (
429
+ "current_observations",
430
+ _SCHEMA[1],
431
+ "boundary_digest,provider,runtime_kind,identity_digest,state,"
432
+ "policy_version,identity_json,observed_at,updated_at",
433
+ ),
434
+ (
435
+ "lifecycle_events",
436
+ _SCHEMA[2],
437
+ "event_id,boundary_digest,provider,runtime_kind,"
438
+ "previous_identity_digest,current_identity_digest,previous_state,"
439
+ "current_state,reason,policy_version,occurred_at,payload_json,payload_hash",
440
+ ),
441
+ )
442
+ try:
443
+ for table, create_sql, columns in rebuilds:
444
+ rebuilt = create_sql.replace(
445
+ f"CREATE TABLE IF NOT EXISTS {table} ",
446
+ f"CREATE TABLE IF NOT EXISTS {table}__v2 ",
447
+ 1,
448
+ )
449
+ if rebuilt == create_sql:
450
+ raise LifecycleStorageError("lifecycle_migration_failed")
451
+ connection.execute(rebuilt)
452
+ connection.execute(
453
+ f"INSERT INTO {table}__v2 ({columns}) SELECT {columns} FROM {table}"
454
+ )
455
+ before = connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
456
+ after = connection.execute(
457
+ f"SELECT COUNT(*) FROM {table}__v2"
458
+ ).fetchone()[0]
459
+ if before != after:
460
+ raise LifecycleStorageError("lifecycle_migration_quarantined")
461
+ connection.execute(f"DROP TABLE {table}")
462
+ connection.execute(f"ALTER TABLE {table}__v2 RENAME TO {table}")
463
+ if connection.execute("PRAGMA foreign_key_check").fetchall():
464
+ raise LifecycleStorageError("lifecycle_migration_quarantined")
465
+ integrity = connection.execute("PRAGMA integrity_check").fetchone()
466
+ if integrity is None or integrity[0] != "ok":
467
+ raise LifecycleStorageError("lifecycle_migration_quarantined")
468
+ connection.execute(
469
+ """INSERT INTO schema_meta(key,value) VALUES('schema_version','2')
470
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value"""
471
+ )
472
+ except LifecycleStorageError:
473
+ raise
474
+ except sqlite3.Error as exc:
475
+ raise LifecycleStorageError("lifecycle_migration_failed") from exc
476
+
477
+ @staticmethod
478
+ def _validate_schema(connection: sqlite3.Connection) -> None:
479
+ expected = {
480
+ "current_observations": {
481
+ "boundary_digest", "provider", "runtime_kind", "identity_digest",
482
+ "state", "policy_version", "identity_json", "observed_at", "updated_at",
483
+ },
484
+ "lifecycle_events": {
485
+ "event_id", "boundary_digest", "provider", "runtime_kind",
486
+ "previous_identity_digest", "current_identity_digest", "previous_state",
487
+ "current_state", "reason", "policy_version", "occurred_at",
488
+ "payload_json", "payload_hash",
489
+ },
490
+ "authority_invalidations": {
491
+ "invalidation_id", "boundary_digest", "previous_identity_digest",
492
+ "current_identity_digest", "target_kind", "target_id", "reason",
493
+ "invalidated_at",
494
+ },
495
+ }
496
+ actual = {
497
+ str(row[0])
498
+ for row in connection.execute(
499
+ "SELECT name FROM sqlite_master WHERE type='table'"
500
+ )
501
+ }
502
+ if not set(expected) <= actual:
503
+ raise LifecycleStorageError("lifecycle_rollback_required")
504
+ for table, required_columns in expected.items():
505
+ columns = {
506
+ str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})")
507
+ }
508
+ if not required_columns <= columns:
509
+ raise LifecycleStorageError("lifecycle_rollback_required")
510
+
511
+ @staticmethod
512
+ def _validate_integrity(connection: sqlite3.Connection) -> None:
513
+ integrity = connection.execute("PRAGMA integrity_check").fetchone()
514
+ foreign_keys = connection.execute("PRAGMA foreign_key_check").fetchall()
515
+ if integrity is None or integrity[0] != "ok" or foreign_keys:
516
+ raise LifecycleStorageError("lifecycle_storage_corrupt")
517
+
518
+ def integrity_check(self) -> str:
519
+ try:
520
+ with self._connection() as connection:
521
+ self._validate_integrity(connection)
522
+ except LifecycleStorageError:
523
+ raise
524
+ except sqlite3.Error as exc:
525
+ raise _translate_database_error(exc) from exc
526
+ return "ok"
527
+
528
+ def pragma_state(self) -> dict[str, int | str]:
529
+ try:
530
+ with self._connection() as connection:
531
+ return {
532
+ "foreign_keys": int(connection.execute("PRAGMA foreign_keys").fetchone()[0]),
533
+ "journal_mode": str(connection.execute("PRAGMA journal_mode").fetchone()[0]).casefold(),
534
+ "busy_timeout": int(connection.execute("PRAGMA busy_timeout").fetchone()[0]),
535
+ }
536
+ except sqlite3.Error as exc:
537
+ raise _translate_database_error(exc) from exc
538
+
539
+ def record_transition(
540
+ self,
541
+ boundary_digest: str,
542
+ identity: ProviderRuntimeIdentity | None,
543
+ event: ProviderLifecycleEvent,
544
+ ) -> bool:
545
+ boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
546
+ if identity is not None and not isinstance(identity, ProviderRuntimeIdentity):
547
+ raise ValueError("lifecycle_record_invalid")
548
+ if not isinstance(event, ProviderLifecycleEvent):
549
+ raise ValueError("lifecycle_record_invalid")
550
+ event_payload = _canonical_json(event.to_dict())
551
+ event_hash = _payload_hash(event_payload)
552
+ identity_payload = None if identity is None else _canonical_json(identity.to_dict())
553
+ connection: sqlite3.Connection | None = None
554
+ try:
555
+ connection = self._connect()
556
+ connection.execute("BEGIN IMMEDIATE")
557
+ existing_event = connection.execute(
558
+ "SELECT boundary_digest,payload_hash FROM lifecycle_events WHERE event_id=?",
559
+ (event.event_id,),
560
+ ).fetchone()
561
+ if existing_event is not None:
562
+ if existing_event["boundary_digest"] != boundary or existing_event["payload_hash"] != event_hash:
563
+ raise LifecycleStorageError("lifecycle_event_changed")
564
+ connection.rollback()
565
+ return False
566
+ if identity is None:
567
+ if (
568
+ event.current_state is not ProviderLifecycleState.UNAVAILABLE
569
+ or event.current_identity_digest is not None
570
+ ):
571
+ raise LifecycleStorageError("lifecycle_identity_mismatch")
572
+ else:
573
+ if (
574
+ event.provider is not identity.provider
575
+ or event.runtime_kind is not identity.runtime_kind
576
+ or event.current_identity_digest != identity.digest
577
+ ):
578
+ raise LifecycleStorageError("lifecycle_identity_mismatch")
579
+ if event.policy_version != identity.policy_version:
580
+ raise LifecycleStorageError("lifecycle_policy_mismatch")
581
+ if event.occurred_at < identity.observed_at:
582
+ raise LifecycleStorageError("lifecycle_observation_time_mismatch")
583
+ current = connection.execute(
584
+ """SELECT provider,runtime_kind,identity_digest,state,updated_at
585
+ FROM current_observations WHERE boundary_digest=?""",
586
+ (boundary,),
587
+ ).fetchone()
588
+ if current is None:
589
+ if event.previous_identity_digest is not None or event.previous_state is not None:
590
+ raise LifecycleStorageError("lifecycle_transition_stale")
591
+ elif (
592
+ current["provider"] != event.provider.value
593
+ or current["runtime_kind"] != event.runtime_kind.value
594
+ or current["identity_digest"] != event.previous_identity_digest
595
+ or current["state"] != event.previous_state.value
596
+ ):
597
+ raise LifecycleStorageError("lifecycle_transition_stale")
598
+ elif event.occurred_at < int(current["updated_at"]):
599
+ raise LifecycleStorageError("lifecycle_transition_stale")
600
+ connection.execute(
601
+ """INSERT INTO lifecycle_events(
602
+ event_id,boundary_digest,provider,runtime_kind,
603
+ previous_identity_digest,current_identity_digest,
604
+ previous_state,current_state,reason,policy_version,
605
+ occurred_at,payload_json,payload_hash
606
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
607
+ (
608
+ event.event_id,
609
+ boundary,
610
+ event.provider.value,
611
+ event.runtime_kind.value,
612
+ event.previous_identity_digest,
613
+ event.current_identity_digest,
614
+ None if event.previous_state is None else event.previous_state.value,
615
+ event.current_state.value,
616
+ event.reason.value,
617
+ event.policy_version,
618
+ event.occurred_at,
619
+ event_payload,
620
+ event_hash,
621
+ ),
622
+ )
623
+ connection.execute(
624
+ """INSERT INTO current_observations(
625
+ boundary_digest,provider,runtime_kind,identity_digest,state,
626
+ policy_version,identity_json,observed_at,updated_at
627
+ ) VALUES(?,?,?,?,?,?,?,?,?)
628
+ ON CONFLICT(boundary_digest) DO UPDATE SET
629
+ provider=excluded.provider,
630
+ runtime_kind=excluded.runtime_kind,
631
+ identity_digest=excluded.identity_digest,
632
+ state=excluded.state,
633
+ policy_version=excluded.policy_version,
634
+ identity_json=excluded.identity_json,
635
+ observed_at=excluded.observed_at,
636
+ updated_at=excluded.updated_at""",
637
+ (
638
+ boundary,
639
+ event.provider.value,
640
+ event.runtime_kind.value,
641
+ None if identity is None else identity.digest,
642
+ event.current_state.value,
643
+ event.policy_version,
644
+ identity_payload,
645
+ event.occurred_at if identity is None else identity.observed_at,
646
+ event.occurred_at,
647
+ ),
648
+ )
649
+ connection.commit()
650
+ return True
651
+ except LifecycleStorageError:
652
+ if connection is not None and connection.in_transaction:
653
+ connection.rollback()
654
+ raise
655
+ except sqlite3.Error as exc:
656
+ if connection is not None and connection.in_transaction:
657
+ connection.rollback()
658
+ raise _translate_database_error(exc) from exc
659
+ finally:
660
+ if connection is not None:
661
+ connection.close()
662
+
663
+ def current_observation(
664
+ self, boundary_digest: str
665
+ ) -> CurrentLifecycleObservation | None:
666
+ boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
667
+ try:
668
+ with self._connection() as connection:
669
+ row = connection.execute(
670
+ """SELECT boundary_digest,provider,runtime_kind,identity_digest,
671
+ identity_json,state,policy_version,observed_at,updated_at
672
+ FROM current_observations
673
+ WHERE boundary_digest=?""",
674
+ (boundary,),
675
+ ).fetchone()
676
+ except sqlite3.Error as exc:
677
+ raise _translate_database_error(exc) from exc
678
+ if row is None:
679
+ return None
680
+ return self._observation_from_row(row)
681
+
682
+ @staticmethod
683
+ def _observation_from_row(row: sqlite3.Row) -> CurrentLifecycleObservation:
684
+ try:
685
+ identity_payload = row["identity_json"]
686
+ identity = None if identity_payload is None else _identity_from_json(identity_payload)
687
+ observation = CurrentLifecycleObservation(
688
+ boundary_digest=_digest(row["boundary_digest"], "lifecycle_storage_corrupt"),
689
+ provider=LifecycleProviderId(row["provider"]),
690
+ runtime_kind=RuntimeKind(row["runtime_kind"]),
691
+ identity=identity,
692
+ state=ProviderLifecycleState(row["state"]),
693
+ policy_version=str(row["policy_version"]),
694
+ observed_at=int(row["observed_at"]),
695
+ updated_at=int(row["updated_at"]),
696
+ )
697
+ if observation.updated_at < observation.observed_at:
698
+ raise ValueError
699
+ if identity is None:
700
+ if (
701
+ row["identity_digest"] is not None
702
+ or observation.state is not ProviderLifecycleState.UNAVAILABLE
703
+ ):
704
+ raise ValueError
705
+ elif (
706
+ row["identity_digest"] != identity.digest
707
+ or observation.provider is not identity.provider
708
+ or observation.runtime_kind is not identity.runtime_kind
709
+ or observation.policy_version != identity.policy_version
710
+ or observation.observed_at != identity.observed_at
711
+ ):
712
+ raise ValueError
713
+ return observation
714
+ except (TypeError, ValueError) as exc:
715
+ raise LifecycleStorageError("lifecycle_storage_corrupt") from exc
716
+
717
+ def read_current_observation(
718
+ self, boundary_digest: str
719
+ ) -> CurrentLifecycleObservation | None:
720
+ boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
721
+ try:
722
+ with closing(self._connect_readonly()) as connection:
723
+ row = connection.execute(
724
+ """SELECT boundary_digest,provider,runtime_kind,identity_digest,
725
+ identity_json,state,policy_version,observed_at,updated_at
726
+ FROM current_observations
727
+ WHERE boundary_digest=?""",
728
+ (boundary,),
729
+ ).fetchone()
730
+ except LifecycleStorageError:
731
+ raise
732
+ except sqlite3.Error as exc:
733
+ raise _translate_database_error(exc) from exc
734
+ return None if row is None else self._observation_from_row(row)
735
+
736
+ def read_observations(
737
+ self, *, limit: int = MAX_EVENT_PAGE_SIZE
738
+ ) -> tuple[CurrentLifecycleObservation, ...]:
739
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_EVENT_PAGE_SIZE:
740
+ raise ValueError("observations_limit_invalid")
741
+ try:
742
+ with closing(self._connect_readonly()) as connection:
743
+ rows = connection.execute(
744
+ """SELECT boundary_digest,provider,runtime_kind,identity_digest,
745
+ identity_json,state,policy_version,observed_at,updated_at
746
+ FROM current_observations
747
+ ORDER BY updated_at DESC,boundary_digest LIMIT ?""",
748
+ (limit,),
749
+ ).fetchall()
750
+ except LifecycleStorageError:
751
+ raise
752
+ except sqlite3.Error as exc:
753
+ raise _translate_database_error(exc) from exc
754
+ return tuple(self._observation_from_row(row) for row in rows)
755
+
756
+ def read_events(
757
+ self, boundary_digest: str, *, limit: int = MAX_EVENT_PAGE_SIZE
758
+ ) -> tuple[ProviderLifecycleEvent, ...]:
759
+ boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
760
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_EVENT_PAGE_SIZE:
761
+ raise ValueError("events_limit_invalid")
762
+ try:
763
+ with closing(self._connect_readonly()) as connection:
764
+ rows = connection.execute(
765
+ """SELECT payload_json FROM lifecycle_events
766
+ WHERE boundary_digest=? ORDER BY occurred_at DESC,event_id DESC LIMIT ?""",
767
+ (boundary, limit),
768
+ ).fetchall()
769
+ except LifecycleStorageError:
770
+ raise
771
+ except sqlite3.Error as exc:
772
+ raise _translate_database_error(exc) from exc
773
+ return tuple(reversed([_event_from_json(row["payload_json"]) for row in rows]))
774
+
775
+ def events(
776
+ self, boundary_digest: str, *, limit: int = MAX_EVENT_PAGE_SIZE
777
+ ) -> tuple[ProviderLifecycleEvent, ...]:
778
+ boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
779
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_EVENT_PAGE_SIZE:
780
+ raise ValueError("events_limit_invalid")
781
+ try:
782
+ with self._connection() as connection:
783
+ rows = connection.execute(
784
+ """SELECT payload_json FROM lifecycle_events
785
+ WHERE boundary_digest=? ORDER BY occurred_at DESC,event_id DESC LIMIT ?""",
786
+ (boundary, limit),
787
+ ).fetchall()
788
+ except sqlite3.Error as exc:
789
+ raise _translate_database_error(exc) from exc
790
+ return tuple(reversed([_event_from_json(row["payload_json"]) for row in rows]))
791
+
792
+ def record_invalidations(
793
+ self,
794
+ *,
795
+ boundary_digest: str,
796
+ previous_identity_digest: str,
797
+ current_identity_digest: str,
798
+ capability_snapshot_digests: tuple[str, ...],
799
+ approval_ids: tuple[str, ...],
800
+ reason: LifecycleReasonCode,
801
+ invalidated_at: int,
802
+ ) -> int:
803
+ boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
804
+ previous = _digest(previous_identity_digest, "previous_identity_digest_invalid")
805
+ current = _digest(current_identity_digest, "current_identity_digest_invalid")
806
+ try:
807
+ normalized_reason = LifecycleReasonCode(reason)
808
+ except (TypeError, ValueError) as exc:
809
+ raise ValueError("lifecycle_reason_invalid") from exc
810
+ observed = _timestamp(invalidated_at, "invalidated_at_invalid")
811
+ if not isinstance(capability_snapshot_digests, (tuple, list)) or not isinstance(
812
+ approval_ids, (tuple, list)
813
+ ):
814
+ raise ValueError("invalidation_targets_invalid")
815
+ if len(capability_snapshot_digests) + len(approval_ids) > MAX_INVALIDATION_TARGETS:
816
+ raise ValueError("invalidation_targets_invalid")
817
+ targets = [
818
+ ("capability_snapshot", _digest(value, "capability_snapshot_digest_invalid"))
819
+ for value in capability_snapshot_digests
820
+ ]
821
+ targets.extend(("approval", _identifier(value, "approval_id_invalid")) for value in approval_ids)
822
+ if len(set(targets)) != len(targets):
823
+ raise ValueError("invalidation_targets_invalid")
824
+ inserted = 0
825
+ connection: sqlite3.Connection | None = None
826
+ try:
827
+ connection = self._connect()
828
+ connection.execute("BEGIN IMMEDIATE")
829
+ for target_kind, target_id in sorted(targets):
830
+ payload = {
831
+ "boundary_digest": boundary,
832
+ "previous_identity_digest": previous,
833
+ "current_identity_digest": current,
834
+ "target_kind": target_kind,
835
+ "target_id": target_id,
836
+ "reason": normalized_reason.value,
837
+ "invalidated_at": observed,
838
+ }
839
+ invalidation_id = _payload_hash(_canonical_json(payload))
840
+ cursor = connection.execute(
841
+ """INSERT OR IGNORE INTO authority_invalidations(
842
+ invalidation_id,boundary_digest,previous_identity_digest,
843
+ current_identity_digest,target_kind,target_id,reason,invalidated_at
844
+ ) VALUES(?,?,?,?,?,?,?,?)""",
845
+ (
846
+ invalidation_id,
847
+ boundary,
848
+ previous,
849
+ current,
850
+ target_kind,
851
+ target_id,
852
+ normalized_reason.value,
853
+ observed,
854
+ ),
855
+ )
856
+ inserted += cursor.rowcount
857
+ connection.commit()
858
+ return inserted
859
+ except sqlite3.Error as exc:
860
+ if connection is not None and connection.in_transaction:
861
+ connection.rollback()
862
+ raise _translate_database_error(exc) from exc
863
+ finally:
864
+ if connection is not None:
865
+ connection.close()
866
+
867
+ def invalidations(
868
+ self, boundary_digest: str, *, limit: int = MAX_EVENT_PAGE_SIZE
869
+ ) -> tuple[LifecycleInvalidationRecord, ...]:
870
+ boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
871
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_EVENT_PAGE_SIZE:
872
+ raise ValueError("invalidations_limit_invalid")
873
+ try:
874
+ with self._connection() as connection:
875
+ rows = connection.execute(
876
+ """SELECT * FROM authority_invalidations WHERE boundary_digest=?
877
+ ORDER BY invalidated_at,invalidation_id LIMIT ?""",
878
+ (boundary, limit),
879
+ ).fetchall()
880
+ except sqlite3.Error as exc:
881
+ raise _translate_database_error(exc) from exc
882
+ try:
883
+ return tuple(
884
+ LifecycleInvalidationRecord(
885
+ invalidation_id=row["invalidation_id"],
886
+ boundary_digest=row["boundary_digest"],
887
+ previous_identity_digest=row["previous_identity_digest"],
888
+ current_identity_digest=row["current_identity_digest"],
889
+ target_kind=row["target_kind"],
890
+ target_id=row["target_id"],
891
+ reason=LifecycleReasonCode(row["reason"]),
892
+ invalidated_at=int(row["invalidated_at"]),
893
+ )
894
+ for row in rows
895
+ )
896
+ except (TypeError, ValueError) as exc:
897
+ raise LifecycleStorageError("lifecycle_storage_corrupt") from exc
898
+
899
+ @staticmethod
900
+ def _file_sha256(path: Path) -> str:
901
+ digest = hashlib.sha256()
902
+ try:
903
+ with path.open("rb") as stream:
904
+ while chunk := stream.read(1024 * 1024):
905
+ digest.update(chunk)
906
+ except OSError as exc:
907
+ raise LifecycleStorageError("lifecycle_backup_failed") from exc
908
+ return digest.hexdigest()
909
+
910
+ @staticmethod
911
+ def _atomic_private_write(path: Path, payload: bytes) -> None:
912
+ temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
913
+ descriptor: int | None = None
914
+ try:
915
+ descriptor = os.open(
916
+ temporary,
917
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
918
+ 0o600,
919
+ )
920
+ written = os.write(descriptor, payload)
921
+ if written != len(payload):
922
+ raise LifecycleStorageError("lifecycle_backup_failed")
923
+ os.fsync(descriptor)
924
+ os.close(descriptor)
925
+ descriptor = None
926
+ os.replace(temporary, path)
927
+ _secure_file(path)
928
+ except LifecycleStorageError:
929
+ raise
930
+ except OSError as exc:
931
+ raise LifecycleStorageError("lifecycle_backup_failed") from exc
932
+ finally:
933
+ if descriptor is not None:
934
+ os.close(descriptor)
935
+ try:
936
+ temporary.unlink(missing_ok=True)
937
+ except OSError:
938
+ pass
939
+
940
+ def create_verified_backup(self) -> tuple[Path, Path]:
941
+ backup = self.backup_path
942
+ marker = self.backup_marker_path
943
+ _secure_repository_directory(self.root, backup.parent)
944
+ temporary = backup.with_name(f".{backup.name}.{secrets.token_hex(8)}.tmp")
945
+ try:
946
+ _validate_database_file(backup)
947
+ with closing(
948
+ sqlite3.connect(self.path, timeout=self.busy_timeout_ms / 1_000)
949
+ ) as source:
950
+ with closing(sqlite3.connect(temporary)) as destination:
951
+ source.backup(destination)
952
+ integrity = destination.execute("PRAGMA integrity_check").fetchone()
953
+ version = destination.execute(
954
+ "SELECT value FROM schema_meta WHERE key='schema_version'"
955
+ ).fetchone()
956
+ if integrity != ("ok",) or version != (LIFECYCLE_SCHEMA_VERSION,):
957
+ raise LifecycleStorageError("lifecycle_backup_failed")
958
+ _secure_file(temporary)
959
+ os.replace(temporary, backup)
960
+ _secure_file(backup)
961
+ evidence = _canonical_json(
962
+ {
963
+ "backup_sha256": self._file_sha256(backup),
964
+ "schema_version": LIFECYCLE_SCHEMA_VERSION,
965
+ }
966
+ ).encode("utf-8")
967
+ self._atomic_private_write(marker, evidence)
968
+ return backup, marker
969
+ except LifecycleStorageError:
970
+ raise
971
+ except (OSError, sqlite3.Error) as exc:
972
+ raise LifecycleStorageError("lifecycle_backup_failed") from exc
973
+ finally:
974
+ try:
975
+ temporary.unlink(missing_ok=True)
976
+ except OSError:
977
+ pass