engineering-platform 2.2.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.
- engineering_platform/ENGINEERING_PLATFORM_CONFIG.json +32 -0
- engineering_platform/ENGINEERING_PLATFORM_VERSION.json +15 -0
- engineering_platform/__init__.py +1 -0
- engineering_platform/__main__.py +7 -0
- engineering_platform/agent_state.py +530 -0
- engineering_platform/agent_trust.py +174 -0
- engineering_platform/assets/dashboard.css +1317 -0
- engineering_platform/assets/dashboard.js +8534 -0
- engineering_platform/assets/dashboard_locales.mjs +4049 -0
- engineering_platform/assets/dashboard_status_store.mjs +41 -0
- engineering_platform/assets/operations-console/apple-touch-icon-dark.png +0 -0
- engineering_platform/assets/operations-console/apple-touch-icon-light.png +0 -0
- engineering_platform/assets/operations-console/icon-dark.png +0 -0
- engineering_platform/assets/operations-console/icon-light.png +0 -0
- engineering_platform/assets/operations-console/icon-transparent.png +0 -0
- engineering_platform/assets/operations-console/manifest.webmanifest +11 -0
- engineering_platform/capability_preflight.py +285 -0
- engineering_platform/capability_review.py +261 -0
- engineering_platform/central_data_transfer.py +195 -0
- engineering_platform/central_database.py +245 -0
- engineering_platform/central_store_migration.py +1672 -0
- engineering_platform/codex_capacity.py +81 -0
- engineering_platform/codex_chat.py +226 -0
- engineering_platform/codex_observability.py +153 -0
- engineering_platform/component_lock.py +40 -0
- engineering_platform/component_logging.py +420 -0
- engineering_platform/console_presentation.py +14 -0
- engineering_platform/console_route_ownership.py +83 -0
- engineering_platform/contracts/__init__.py +38 -0
- engineering_platform/contracts/ep_consumer.py +391 -0
- engineering_platform/contracts/models.py +105 -0
- engineering_platform/contracts/projection.py +401 -0
- engineering_platform/dashboard_browser_validation.py +206 -0
- engineering_platform/dashboard_state.py +630 -0
- engineering_platform/dashboard_supervisor.swift +105 -0
- engineering_platform/dashboard_translation.py +129 -0
- engineering_platform/dependabot_producer.py +349 -0
- engineering_platform/drift_diagnostics.py +144 -0
- engineering_platform/emergency_recovery.py +268 -0
- engineering_platform/engineering_memory.py +139 -0
- engineering_platform/ep_consumer_credentials.py +473 -0
- engineering_platform/evidence_projection.py +213 -0
- engineering_platform/execution_activity.py +218 -0
- engineering_platform/execution_context.py +132 -0
- engineering_platform/execution_errors.py +42 -0
- engineering_platform/execution_evidence.py +24 -0
- engineering_platform/execution_executor.py +730 -0
- engineering_platform/execution_finalization.py +44 -0
- engineering_platform/execution_host.py +3306 -0
- engineering_platform/execution_lease.py +365 -0
- engineering_platform/execution_lifecycle.py +447 -0
- engineering_platform/execution_models.py +43 -0
- engineering_platform/execution_readiness.py +166 -0
- engineering_platform/execution_reporting.py +1607 -0
- engineering_platform/execution_repository.py +253 -0
- engineering_platform/execution_timeout_policy.py +56 -0
- engineering_platform/execution_timing.py +440 -0
- engineering_platform/execution_transaction.py +28 -0
- engineering_platform/external_producer_binding.py +235 -0
- engineering_platform/file_inbox.py +249 -0
- engineering_platform/forensic_attribution.py +338 -0
- engineering_platform/forensic_attribution_v2.py +134 -0
- engineering_platform/forensic_delta.py +299 -0
- engineering_platform/golden_scenario.py +63 -0
- engineering_platform/historical_dashboard_configuration.py +171 -0
- engineering_platform/host_admin.py +199 -0
- engineering_platform/host_preflight.py +231 -0
- engineering_platform/installation_relocation.py +122 -0
- engineering_platform/investigation_ledger.py +89 -0
- engineering_platform/legacy_inbox_migration.py +79 -0
- engineering_platform/lifecycle_worker.py +223 -0
- engineering_platform/live_status.py +267 -0
- engineering_platform/local_api.py +209 -0
- engineering_platform/local_api_keychain.py +51 -0
- engineering_platform/local_repository_binding.py +138 -0
- engineering_platform/managed_autonomy.py +509 -0
- engineering_platform/managed_codex_runtime.py +105 -0
- engineering_platform/parity_context.py +203 -0
- engineering_platform/parity_lifecycle_dispatcher.py +488 -0
- engineering_platform/platform_admin.py +13 -0
- engineering_platform/platform_api.py +428 -0
- engineering_platform/platform_bootstrap.py +385 -0
- engineering_platform/platform_components.py +65 -0
- engineering_platform/platform_version.py +171 -0
- engineering_platform/pr_check_repair.py +276 -0
- engineering_platform/pr_evidence_backfill.py +278 -0
- engineering_platform/producer.py +209 -0
- engineering_platform/project_agent.py +366 -0
- engineering_platform/project_agent_service.py +244 -0
- engineering_platform/project_topology.py +126 -0
- engineering_platform/prompt_history.py +591 -0
- engineering_platform/provider_context.py +136 -0
- engineering_platform/provider_context_benchmark.py +41 -0
- engineering_platform/provider_context_scope.py +90 -0
- engineering_platform/provider_interruption.py +168 -0
- engineering_platform/provider_process_identity.py +80 -0
- engineering_platform/provider_readiness.py +138 -0
- engineering_platform/provider_recovery.py +647 -0
- engineering_platform/provider_usage.py +497 -0
- engineering_platform/providers.py +471 -0
- engineering_platform/qualification.py +220 -0
- engineering_platform/recommendation_handoff.py +238 -0
- engineering_platform/report_analysis.py +193 -0
- engineering_platform/repository_attachment.py +171 -0
- engineering_platform/repository_handoff.py +95 -0
- engineering_platform/resources.py +38 -0
- engineering_platform/reviewer_evidence.py +70 -0
- engineering_platform/schemas/repository-attachment.schema.json +61 -0
- engineering_platform/server.py +3679 -0
- engineering_platform/server_console_services.py +2024 -0
- engineering_platform/server_relay.py +172 -0
- engineering_platform/server_service.py +122 -0
- engineering_platform/status_model.py +135 -0
- engineering_platform/status_reconciliation.py +34 -0
- engineering_platform/storage.py +2440 -0
- engineering_platform/submission_cli.py +77 -0
- engineering_platform/submission_intake.py +45 -0
- engineering_platform/submission_service.py +317 -0
- engineering_platform/telemetry.py +951 -0
- engineering_platform/templates/workspace-config.json +25 -0
- engineering_platform/validation_identity.py +50 -0
- engineering_platform/validation_profile.py +211 -0
- engineering_platform/workspace_preflight.py +263 -0
- engineering_platform/worktree_provenance.py +147 -0
- engineering_platform/worktree_tooling.py +18 -0
- engineering_platform-2.2.0.dist-info/METADATA +18 -0
- engineering_platform-2.2.0.dist-info/RECORD +130 -0
- engineering_platform-2.2.0.dist-info/WHEEL +5 -0
- engineering_platform-2.2.0.dist-info/entry_points.txt +6 -0
- engineering_platform-2.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1672 @@
|
|
|
1
|
+
"""Read-only Engineering Platform central-store migration preflight.
|
|
2
|
+
|
|
3
|
+
This module deliberately never opens the legacy store through ``open_storage``:
|
|
4
|
+
that API owns normal write/migration behaviour. Increment 2 may inspect and
|
|
5
|
+
compare stores, but it cannot copy, create, checkpoint, freeze, stop, or hand
|
|
6
|
+
off an authority.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
import fcntl
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import shutil
|
|
20
|
+
import sqlite3
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import unicodedata
|
|
24
|
+
import uuid
|
|
25
|
+
|
|
26
|
+
from .storage import DATABASE_FILENAME, ENGINEERING_STORAGE_SCHEMA_VERSION, EngineeringStorageError, database_path, legacy_database_path
|
|
27
|
+
from .providers import LaunchdProvider
|
|
28
|
+
from .forensic_attribution import ForensicAttributionError, canonical_attribution_json, load_and_attribute
|
|
29
|
+
from .forensic_attribution_v2 import ForensicAttributionV2Error, canonical_attribution_v2_json, load_and_enrich_v2
|
|
30
|
+
from .forensic_delta import ForensicDeltaError, canonical_report_json, export_forensic_delta
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
TOOL_VERSION = "2.0.0-phase2-increment3"
|
|
34
|
+
EXPECTED_SCHEMA = ENGINEERING_STORAGE_SCHEMA_VERSION
|
|
35
|
+
HISTORICAL_ATTESTATION_VERSION = 1
|
|
36
|
+
HISTORICAL_ATTESTATION_CLASSIFIER_VERSION = 1
|
|
37
|
+
_BASE_REQUIRED_TABLES = frozenset({
|
|
38
|
+
"engineering_schema_migrations", "engineering_metadata", "engineering_transactions",
|
|
39
|
+
"execution_run_leases", "provider_recovery_attempts", "execution_runs", "execution_submissions",
|
|
40
|
+
"provider_invocation_receipts", "prompt_execution_history",
|
|
41
|
+
"execution_run_qualification_snapshots",
|
|
42
|
+
})
|
|
43
|
+
_CURRENT_CONSUMER_TABLES = ("ep_consumer_credentials", "ep_consumer_registrations")
|
|
44
|
+
_LEGACY_CONSUMER_TABLES = ("local_api_credentials", "local_api_consumer_registrations")
|
|
45
|
+
REQUIRED_TABLES = _BASE_REQUIRED_TABLES | frozenset(_CURRENT_CONSUMER_TABLES)
|
|
46
|
+
TERMINAL_PHASES = ("COMPLETE", "BLOCKED", "FAILED")
|
|
47
|
+
ACTIVE_RECOVERY_STATES = ("RECOVERY_AVAILABLE", "RECOVERY_STARTING", "RECOVERY_IN_PROGRESS")
|
|
48
|
+
FAILURE_CODES = frozenset({
|
|
49
|
+
"LEGACY_STORE_NOT_FOUND", "LEGACY_STORE_AMBIGUOUS", "TARGET_STORE_CONFLICT",
|
|
50
|
+
"ACTIVE_EXECUTION", "ACTIVE_LEASE", "SOURCE_SCHEMA_MISMATCH",
|
|
51
|
+
"SOURCE_INTEGRITY_FAILED", "BACKUP_NOT_READY", "TARGET_UNREADABLE",
|
|
52
|
+
"PROJECT_SCOPE_UNRESOLVED", "AUTHORITY_HANDOFF_NOT_SAFE", "ABORT_PRE_HANDOFF_FAILED",
|
|
53
|
+
"LEGITIMATE_CENTRAL_WRITE_PRESENT", "CONTAMINATION_PROVENANCE_UNRESOLVED",
|
|
54
|
+
"FORENSIC_CENTRAL_UNREADABLE", "LEGACY_BASELINE_MISMATCH",
|
|
55
|
+
"RECOVERY_SERVICE_QUIESCENCE_FAILED", "RECOVERY_AUTHORITY_SWITCH_FAILED",
|
|
56
|
+
"RECOVERY_SERVICE_RESTART_FAILED", "RECOVERY_MIXED_BINDING", "RECOVERY_POSTCHECK_FAILED",
|
|
57
|
+
})
|
|
58
|
+
CONTROL_KEY = "admission_freeze.v1"
|
|
59
|
+
STATE_KEY = "central_store_cutover.v1"
|
|
60
|
+
POINTER_VERSION = 1
|
|
61
|
+
STATES = (
|
|
62
|
+
"PRECHECK", "ADMISSION_FROZEN", "QUIESCENT_SOURCE_BASELINE", "BACKUP_VERIFIED",
|
|
63
|
+
"CENTRAL_STORE_CREATED", "TARGET_VERIFIED", "AUTHORITY_SWITCHED",
|
|
64
|
+
"SERVICES_RESTARTED", "POST_CUTOVER_VERIFIED",
|
|
65
|
+
"LEGACY_ROLLBACK_COMPATIBLE", "CENTRAL_STORE_ACTIVE_POST_WRITE", "ABORTED_PRE_HANDOFF",
|
|
66
|
+
"CONTAMINATED_RECOVERY_PRECHECK", "RECOVERY_SERVICES_QUIESCED",
|
|
67
|
+
"RECOVERY_LEGACY_VERIFIED", "ROLLBACK_IN_PROGRESS", "ROLLBACK_COMPLETED",
|
|
68
|
+
)
|
|
69
|
+
ABORTABLE_STATES = frozenset({"PRECHECK", "ADMISSION_FROZEN", "QUIESCENT_SOURCE_BASELINE"})
|
|
70
|
+
ABORT_REASONS = frozenset({"CONTROLLER_VERSION_INCOMPATIBLE", "PRE_HANDOFF_CONTROLLER_DEFECT"})
|
|
71
|
+
# CENTRAL cutover may control only the current relay. The other two labels are
|
|
72
|
+
# retained below solely to recognize a stale pre-P-NEUTRAL lock; they are never
|
|
73
|
+
# installed, restarted or selected as lifecycle authority by this module.
|
|
74
|
+
SERVICE_STOP_ORDER = ("com.engineeringplatform.dashboard-relay",)
|
|
75
|
+
SERVICE_START_ORDER = ("com.engineeringplatform.dashboard-relay",)
|
|
76
|
+
HISTORICAL_LOCK_IDENTITIES = {
|
|
77
|
+
"dashboard.lock": ("dashboard", "engineering_platform.dashboard", "com.djconnect.engineering-dashboard"),
|
|
78
|
+
"inbox-watcher.lock": ("inbox-watcher", "engineering_platform.inbox_watcher", "com.djconnect.engineering-inbox"),
|
|
79
|
+
}
|
|
80
|
+
EXPECTED_RUNNING_LOCKS = {
|
|
81
|
+
**HISTORICAL_LOCK_IDENTITIES,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CutoverError(RuntimeError):
|
|
86
|
+
"""Stable fail-closed cutover error."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, code: str, detail: str = "") -> None:
|
|
89
|
+
super().__init__(code if not detail else f"{code}: {detail}")
|
|
90
|
+
self.code = code
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class LaunchAgentServiceControl:
|
|
94
|
+
"""Canonical macOS LaunchAgent adapter; invoked only by explicit cutover CLI."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, uid: int | None = None) -> None:
|
|
97
|
+
self._domain = f"gui/{uid if uid is not None else os.getuid()}"
|
|
98
|
+
self._launchd = LaunchdProvider()
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _plist(label: str) -> Path:
|
|
102
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
|
103
|
+
|
|
104
|
+
def stop(self, label: str) -> None:
|
|
105
|
+
try:
|
|
106
|
+
self._launchd.quiesce(label, self._plist(label))
|
|
107
|
+
except OSError as error:
|
|
108
|
+
raise CutoverError("SERVICE_STOP_FAILED", label) from error
|
|
109
|
+
if not self.stopped(label):
|
|
110
|
+
raise CutoverError("SERVICE_STOP_FAILED", label)
|
|
111
|
+
|
|
112
|
+
def start(self, label: str) -> None:
|
|
113
|
+
try:
|
|
114
|
+
self._launchd.resume(label, self._plist(label))
|
|
115
|
+
except OSError as error:
|
|
116
|
+
raise CutoverError("SERVICE_RESTART_FAILED", label) from error
|
|
117
|
+
if not self.running(label):
|
|
118
|
+
raise CutoverError("SERVICE_RESTART_FAILED", label)
|
|
119
|
+
|
|
120
|
+
def stopped(self, label: str) -> bool:
|
|
121
|
+
return not self._launchd.inspect(label)
|
|
122
|
+
|
|
123
|
+
def running(self, label: str) -> bool:
|
|
124
|
+
result = subprocess.run(["launchctl", "print", f"{self._domain}/{label}"], capture_output=True, text=True, check=False)
|
|
125
|
+
return result.returncode == 0 and "state = running" in result.stdout
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class StoreCandidate:
|
|
130
|
+
path: str
|
|
131
|
+
resolved_path: str
|
|
132
|
+
provenance: tuple[str, ...]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class StoreIdentity:
|
|
137
|
+
path: str
|
|
138
|
+
resolved_path: str
|
|
139
|
+
size_bytes: int
|
|
140
|
+
modified_ns: int
|
|
141
|
+
fingerprint_sha256: str
|
|
142
|
+
schema_version: int | None
|
|
143
|
+
provenance: tuple[str, ...]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def user_data_dir(app_name: str = "Engineering Platform") -> Path:
|
|
147
|
+
"""Return the portable per-user application-data directory.
|
|
148
|
+
|
|
149
|
+
This is the repository's dependency-free equivalent of
|
|
150
|
+
``platformdirs.user_data_dir``. It follows platform application-data
|
|
151
|
+
conventions without embedding a user or checkout path in the contract.
|
|
152
|
+
"""
|
|
153
|
+
if sys.platform == "darwin":
|
|
154
|
+
return Path.home() / "Library" / "Application Support" / app_name
|
|
155
|
+
if os.name == "nt":
|
|
156
|
+
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
|
|
157
|
+
return Path(base) / app_name if base else Path.home() / "AppData" / "Local" / app_name
|
|
158
|
+
base = os.environ.get("XDG_DATA_HOME")
|
|
159
|
+
return Path(base) / app_name if base else Path.home() / ".local" / "share" / app_name
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def installation_data_root() -> Path:
|
|
163
|
+
"""Resolve the one canonical, installation-owned EP data root."""
|
|
164
|
+
return user_data_dir("Engineering Platform")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def central_store_path() -> Path:
|
|
168
|
+
"""Return the future central store path without creating it."""
|
|
169
|
+
return installation_data_root() / DATABASE_FILENAME
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _readonly(path: Path) -> sqlite3.Connection:
|
|
173
|
+
return sqlite3.connect(f"file:{path.resolve().as_posix()}?mode=ro", uri=True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _tables(connection: sqlite3.Connection) -> set[str]:
|
|
177
|
+
return {str(row[0]) for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _schema(connection: sqlite3.Connection, tables: set[str] | None = None) -> int | None:
|
|
181
|
+
tables = tables if tables is not None else _tables(connection)
|
|
182
|
+
if "engineering_schema_migrations" not in tables:
|
|
183
|
+
return None
|
|
184
|
+
row = connection.execute("SELECT MAX(version) FROM engineering_schema_migrations").fetchone()
|
|
185
|
+
return int(row[0]) if row and row[0] is not None else 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _fingerprint(path: Path) -> str:
|
|
189
|
+
digest = hashlib.sha256()
|
|
190
|
+
with path.open("rb") as handle:
|
|
191
|
+
while chunk := handle.read(1_048_576):
|
|
192
|
+
digest.update(chunk)
|
|
193
|
+
return digest.hexdigest()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def discover_legacy_stores(repo: Path, *, extra_runtime_roots: tuple[Path, ...] = ()) -> tuple[StoreCandidate, ...]:
|
|
197
|
+
"""Discover only canonical runtime-resolver candidates; never scan disks."""
|
|
198
|
+
roots = ((repo.resolve(), "storage.database_path(repo)"),) + tuple(
|
|
199
|
+
(root.resolve(), "explicit_runtime_evidence") for root in extra_runtime_roots
|
|
200
|
+
)
|
|
201
|
+
grouped: dict[Path, set[str]] = {}
|
|
202
|
+
for root, provenance in roots:
|
|
203
|
+
candidate = legacy_database_path(root).resolve()
|
|
204
|
+
if candidate.is_file():
|
|
205
|
+
grouped.setdefault(candidate, set()).add(provenance)
|
|
206
|
+
return tuple(
|
|
207
|
+
StoreCandidate(str(path), str(path), tuple(sorted(provenance)))
|
|
208
|
+
for path, provenance in sorted(grouped.items(), key=lambda item: str(item[0]))
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def source_identity(candidate: StoreCandidate) -> StoreIdentity:
|
|
213
|
+
path = Path(candidate.resolved_path)
|
|
214
|
+
stat = path.stat()
|
|
215
|
+
schema: int | None = None
|
|
216
|
+
try:
|
|
217
|
+
with _readonly(path) as connection:
|
|
218
|
+
schema = _schema(connection)
|
|
219
|
+
except (OSError, sqlite3.DatabaseError):
|
|
220
|
+
pass
|
|
221
|
+
return StoreIdentity(candidate.path, candidate.resolved_path, stat.st_size, stat.st_mtime_ns, _fingerprint(path), schema, candidate.provenance)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _same_source_content(left: StoreIdentity, right: StoreIdentity | dict[str, object]) -> bool:
|
|
225
|
+
"""Compare source authority content; filesystem timestamps are not evidence writes."""
|
|
226
|
+
values = asdict(left)
|
|
227
|
+
other = asdict(right) if isinstance(right, StoreIdentity) else right
|
|
228
|
+
return all(values.get(key) == other.get(key) for key in ("resolved_path", "size_bytes", "fingerprint_sha256", "schema_version"))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def classify_target(path: Path) -> dict[str, object]:
|
|
232
|
+
"""Classify a target without creating, modifying, or repairing it."""
|
|
233
|
+
if not path.exists():
|
|
234
|
+
return {"state": "ABSENT", "path": str(path), "blocking_code": None}
|
|
235
|
+
if not path.is_file():
|
|
236
|
+
return {"state": "UNKNOWN", "path": str(path), "blocking_code": "TARGET_UNREADABLE"}
|
|
237
|
+
try:
|
|
238
|
+
for receipt_file in (installation_data_root() / "migration").glob("*.json"):
|
|
239
|
+
try:
|
|
240
|
+
receipt = json.loads(receipt_file.read_text(encoding="utf-8"))
|
|
241
|
+
forensic = receipt.get("central_forensic") if isinstance(receipt, dict) else None
|
|
242
|
+
if isinstance(forensic, dict) and forensic.get("path") == str(path.resolve()) and forensic.get("classification") == "FORENSIC_CONTAMINATED_NON_AUTHORITATIVE":
|
|
243
|
+
return {"state": "FORENSIC_CONTAMINATED_NON_AUTHORITATIVE", "path": str(path), "blocking_code": "TARGET_STORE_CONFLICT"}
|
|
244
|
+
except (OSError, json.JSONDecodeError):
|
|
245
|
+
continue
|
|
246
|
+
if path.stat().st_size == 0:
|
|
247
|
+
return {"state": "EMPTY_NEW", "path": str(path), "blocking_code": None}
|
|
248
|
+
with _readonly(path) as connection:
|
|
249
|
+
tables = _tables(connection)
|
|
250
|
+
if not tables:
|
|
251
|
+
return {"state": "EMPTY_NEW", "path": str(path), "blocking_code": None}
|
|
252
|
+
schema = _schema(connection, tables)
|
|
253
|
+
if schema == EXPECTED_SCHEMA and _required_tables(tables) <= tables:
|
|
254
|
+
return {"state": "COMPATIBLE_EXISTING", "path": str(path), "blocking_code": "TARGET_STORE_CONFLICT"}
|
|
255
|
+
return {"state": "CONFLICTING_EXISTING", "path": str(path), "blocking_code": "TARGET_STORE_CONFLICT"}
|
|
256
|
+
except (OSError, sqlite3.DatabaseError):
|
|
257
|
+
return {"state": "CORRUPT_UNREADABLE", "path": str(path), "blocking_code": "TARGET_UNREADABLE"}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def inspect_source(candidate: StoreCandidate) -> dict[str, object]:
|
|
261
|
+
"""Read source schema/integrity/table facts without a write transaction."""
|
|
262
|
+
path = Path(candidate.resolved_path)
|
|
263
|
+
result: dict[str, object] = {"identity": asdict(source_identity(candidate)), "tables": [], "integrity": "FAILED", "blocking_codes": []}
|
|
264
|
+
try:
|
|
265
|
+
with _readonly(path) as connection:
|
|
266
|
+
tables = _tables(connection)
|
|
267
|
+
schema = _schema(connection, tables)
|
|
268
|
+
integrity = [str(row[0]) for row in connection.execute("PRAGMA integrity_check")]
|
|
269
|
+
result.update({"tables": sorted(tables), "schema_version": schema, "integrity": "PASS" if integrity == ["ok"] else "FAILED"})
|
|
270
|
+
if schema != EXPECTED_SCHEMA:
|
|
271
|
+
result["blocking_codes"].append("SOURCE_SCHEMA_MISMATCH")
|
|
272
|
+
required = _required_tables(tables)
|
|
273
|
+
if integrity != ["ok"] or not required <= tables:
|
|
274
|
+
result["blocking_codes"].append("SOURCE_INTEGRITY_FAILED")
|
|
275
|
+
result["required_tables_present"] = sorted(required & tables)
|
|
276
|
+
result["required_tables_missing"] = sorted(required - tables)
|
|
277
|
+
result["journal_mode"] = str(connection.execute("PRAGMA journal_mode").fetchone()[0]).upper()
|
|
278
|
+
except (OSError, sqlite3.DatabaseError):
|
|
279
|
+
result["blocking_codes"].append("SOURCE_INTEGRITY_FAILED")
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _count(connection: sqlite3.Connection, query: str, params: tuple[object, ...] = ()) -> int:
|
|
284
|
+
return int(connection.execute(query, params).fetchone()[0])
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _consumer_authority_tables(tables: set[str]) -> tuple[str, str] | None:
|
|
288
|
+
"""Return the sole authority pair, preferring the current neutral schema.
|
|
289
|
+
|
|
290
|
+
The retained historical pair is readable only for pre-schema-53 migration
|
|
291
|
+
evidence. Once current tables exist, all migration inspection uses the
|
|
292
|
+
neutral pair and never falls back to legacy data.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
if set(_CURRENT_CONSUMER_TABLES) <= tables:
|
|
296
|
+
return _CURRENT_CONSUMER_TABLES
|
|
297
|
+
if set(_LEGACY_CONSUMER_TABLES) <= tables:
|
|
298
|
+
return _LEGACY_CONSUMER_TABLES
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _required_tables(tables: set[str]) -> frozenset[str]:
|
|
303
|
+
pair = _consumer_authority_tables(tables)
|
|
304
|
+
return _BASE_REQUIRED_TABLES | frozenset(pair or _CURRENT_CONSUMER_TABLES)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _consumer_authority_tables_for_path(path: Path) -> tuple[str, str] | None:
|
|
308
|
+
with _readonly(path) as connection:
|
|
309
|
+
return _consumer_authority_tables(_tables(connection))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _lock_owner(lock: Path) -> tuple[str, int] | None:
|
|
313
|
+
try:
|
|
314
|
+
payload = json.loads(lock.read_text(encoding="utf-8"))
|
|
315
|
+
component = payload.get("component") if isinstance(payload, dict) else None
|
|
316
|
+
process_id = payload.get("pid") if isinstance(payload, dict) else None
|
|
317
|
+
if isinstance(component, str) and isinstance(process_id, int) and process_id > 0:
|
|
318
|
+
return component, process_id
|
|
319
|
+
except (OSError, json.JSONDecodeError):
|
|
320
|
+
pass
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _process_command(process_id: int) -> str | None:
|
|
325
|
+
result = subprocess.run(["ps", "-p", str(process_id), "-o", "command="], capture_output=True, text=True, check=False)
|
|
326
|
+
return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _classify_lock(lock: Path, *, pre_stop: bool, services: LaunchAgentServiceControl | None) -> str:
|
|
330
|
+
"""Classify a held component lock without treating its filename as ownership."""
|
|
331
|
+
try:
|
|
332
|
+
with lock.open("r", encoding="utf-8") as handle:
|
|
333
|
+
try:
|
|
334
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
335
|
+
except BlockingIOError:
|
|
336
|
+
pass
|
|
337
|
+
else:
|
|
338
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
339
|
+
owner = _lock_owner(lock)
|
|
340
|
+
expected = EXPECTED_RUNNING_LOCKS.get(lock.name)
|
|
341
|
+
if owner is not None and expected is not None and owner[0] == expected[0]:
|
|
342
|
+
try:
|
|
343
|
+
os.kill(owner[1], 0)
|
|
344
|
+
except ProcessLookupError:
|
|
345
|
+
return "INACTIVE_EXPECTED_SERVICE_LOCK"
|
|
346
|
+
except PermissionError:
|
|
347
|
+
return "UNKNOWN_LOCK"
|
|
348
|
+
return "STALE_UNOWNED_LOCK"
|
|
349
|
+
except OSError:
|
|
350
|
+
return "UNKNOWN_LOCK"
|
|
351
|
+
owner = _lock_owner(lock)
|
|
352
|
+
expected = EXPECTED_RUNNING_LOCKS.get(lock.name)
|
|
353
|
+
if not pre_stop or owner is None or expected is None or services is None:
|
|
354
|
+
return "UNKNOWN_LOCK"
|
|
355
|
+
component, process_id = owner
|
|
356
|
+
expected_component, expected_module, expected_service = expected
|
|
357
|
+
command = _process_command(process_id)
|
|
358
|
+
if component == expected_component and command is not None and expected_module in command and services.running(expected_service):
|
|
359
|
+
return "EXPECTED_RUNNING_SERVICE_LOCK"
|
|
360
|
+
return "UNEXPECTED_LIVE_LOCK"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def inspect_quiescence(path: Path, *, pre_stop: bool = False, services: LaunchAgentServiceControl | None = None) -> dict[str, object]:
|
|
364
|
+
"""Inspect lifecycle blockers; only verified canonical service locks are allowed pre-stop."""
|
|
365
|
+
facts: dict[str, object] = {"non_terminal_transactions": 0, "active_leases": 0, "active_recovery": 0, "unsafe_locks": [], "lock_classifications": {}, "blocking_codes": []}
|
|
366
|
+
try:
|
|
367
|
+
with _readonly(path) as connection:
|
|
368
|
+
tables = _tables(connection)
|
|
369
|
+
if "engineering_transactions" in tables:
|
|
370
|
+
facts["non_terminal_transactions"] = _count(connection, "SELECT COUNT(*) FROM engineering_transactions WHERE phase NOT IN (?,?,?)", TERMINAL_PHASES)
|
|
371
|
+
if "execution_run_leases" in tables:
|
|
372
|
+
facts["active_leases"] = _count(connection, "SELECT COUNT(*) FROM execution_run_leases WHERE lease_state='ACTIVE'")
|
|
373
|
+
if "provider_recovery_attempts" in tables:
|
|
374
|
+
placeholders = ",".join("?" for _ in ACTIVE_RECOVERY_STATES)
|
|
375
|
+
facts["active_recovery"] = _count(connection, f"SELECT COUNT(*) FROM provider_recovery_attempts WHERE state IN ({placeholders})", ACTIVE_RECOVERY_STATES)
|
|
376
|
+
except (OSError, sqlite3.DatabaseError):
|
|
377
|
+
facts["blocking_codes"].append("AUTHORITY_HANDOFF_NOT_SAFE")
|
|
378
|
+
locks = path.parent / "locks"
|
|
379
|
+
if locks.is_dir():
|
|
380
|
+
for lock in sorted(locks.glob("*.lock")):
|
|
381
|
+
classification = _classify_lock(lock, pre_stop=pre_stop, services=services)
|
|
382
|
+
facts["lock_classifications"][lock.name] = classification
|
|
383
|
+
allowed = {"EXPECTED_RUNNING_SERVICE_LOCK"} if pre_stop else {"INACTIVE_EXPECTED_SERVICE_LOCK"}
|
|
384
|
+
if classification not in allowed:
|
|
385
|
+
facts["unsafe_locks"].append(lock.name)
|
|
386
|
+
if facts["non_terminal_transactions"]:
|
|
387
|
+
facts["blocking_codes"].append("ACTIVE_EXECUTION")
|
|
388
|
+
if facts["active_leases"]:
|
|
389
|
+
facts["blocking_codes"].append("ACTIVE_LEASE")
|
|
390
|
+
if facts["active_recovery"] or facts["unsafe_locks"]:
|
|
391
|
+
facts["blocking_codes"].append("AUTHORITY_HANDOFF_NOT_SAFE")
|
|
392
|
+
facts["eligible"] = not facts["blocking_codes"]
|
|
393
|
+
return facts
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def project_scope_inventory(path: Path) -> dict[str, object]:
|
|
397
|
+
"""Return project and credential metadata counts only; never credential values."""
|
|
398
|
+
result: dict[str, object] = {"project_ids": [], "consumer_registrations": 0, "credential_scopes": 0, "run_project_associations": "NOT_PERSISTED", "prompt_history_project_relationships": "NOT_PERSISTED", "plaintext_credential_columns": [], "blocking_codes": []}
|
|
399
|
+
try:
|
|
400
|
+
with _readonly(path) as connection:
|
|
401
|
+
tables = _tables(connection)
|
|
402
|
+
pair = _consumer_authority_tables(tables)
|
|
403
|
+
if pair is None:
|
|
404
|
+
result["blocking_codes"].append("SOURCE_INTEGRITY_FAILED")
|
|
405
|
+
return result
|
|
406
|
+
credentials_table, registrations_table = pair
|
|
407
|
+
if registrations_table in tables:
|
|
408
|
+
rows = connection.execute(f"SELECT DISTINCT project_id FROM {registrations_table} ORDER BY project_id").fetchall()
|
|
409
|
+
result["project_ids"] = [str(row[0]) for row in rows]
|
|
410
|
+
result["consumer_registrations"] = _count(connection, f"SELECT COUNT(*) FROM {registrations_table}")
|
|
411
|
+
if credentials_table in tables:
|
|
412
|
+
result["credential_scopes"] = _count(connection, f"SELECT COUNT(*) FROM {credentials_table}")
|
|
413
|
+
columns = {str(row[1]).casefold() for row in connection.execute(f"PRAGMA table_info({credentials_table})")}
|
|
414
|
+
result["plaintext_credential_columns"] = sorted(columns & {"credential", "token", "bearer", "secret", "plaintext"})
|
|
415
|
+
for table in ("engineering_transactions", "prompt_execution_history"):
|
|
416
|
+
if table in tables:
|
|
417
|
+
columns = {str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})")}
|
|
418
|
+
key = "run_project_associations" if table == "engineering_transactions" else "prompt_history_project_relationships"
|
|
419
|
+
result[key] = "PERSISTED" if "project_id" in columns else "NOT_PERSISTED"
|
|
420
|
+
if result["plaintext_credential_columns"]:
|
|
421
|
+
result["blocking_codes"].append("SOURCE_INTEGRITY_FAILED")
|
|
422
|
+
except (OSError, sqlite3.DatabaseError):
|
|
423
|
+
result["blocking_codes"].append("PROJECT_SCOPE_UNRESOLVED")
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def backup_readiness(identity: StoreIdentity, root: Path) -> dict[str, object]:
|
|
428
|
+
backup = root / "backups" / f"legacy-schema40-{identity.fingerprint_sha256[:16]}-<migration-id>.db"
|
|
429
|
+
ancestor = root
|
|
430
|
+
while not ancestor.exists() and ancestor != ancestor.parent:
|
|
431
|
+
ancestor = ancestor.parent
|
|
432
|
+
writable = os.access(ancestor, os.W_OK | os.X_OK)
|
|
433
|
+
available = shutil.disk_usage(ancestor).free if ancestor.exists() else 0
|
|
434
|
+
ready = writable and available >= identity.size_bytes
|
|
435
|
+
return {"backup_path": str(backup), "root_exists": root.exists(), "writable_ancestor": str(ancestor), "available_bytes": available, "required_bytes": identity.size_bytes, "integrity_method": "PRAGMA integrity_check", "ready": ready, "blocking_code": None if ready else "BACKUP_NOT_READY"}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def snapshot_plan(source: dict[str, object]) -> dict[str, object]:
|
|
439
|
+
identity = source.get("identity", {})
|
|
440
|
+
path = Path(str(identity.get("resolved_path", "")))
|
|
441
|
+
return {"strategy": "sqlite_backup_api", "requires_future_quiescence": True, "source_read_only": True, "temporary_target_then_fsync_atomic_rename": True, "raw_copy_forbidden": True, "journal_mode": source.get("journal_mode"), "wal_present": path.with_name(path.name + "-wal").exists(), "shm_present": path.with_name(path.name + "-shm").exists(), "checkpoint_requirement": "PRAGMA wal_checkpoint(TRUNCATE) by the sole controlled writer before copy"}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _table_counts(path: Path) -> dict[str, int]:
|
|
445
|
+
with _readonly(path) as connection:
|
|
446
|
+
return {table: _count(connection, f'SELECT COUNT(*) FROM "{table}"') for table in sorted(_tables(connection)) if not table.startswith("sqlite_")}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def validate_target_equivalence(source: Path, target: Path) -> dict[str, object]:
|
|
450
|
+
"""Compare a candidate target read-only; intended for test/future cutover use."""
|
|
451
|
+
result: dict[str, object] = {"equivalent": False, "blocking_codes": [], "differences": []}
|
|
452
|
+
try:
|
|
453
|
+
source_facts, target_facts = inspect_source(StoreCandidate(str(source), str(source.resolve()), ("comparison_source",))), inspect_source(StoreCandidate(str(target), str(target.resolve()), ("comparison_target",)))
|
|
454
|
+
if source_facts.get("schema_version") != target_facts.get("schema_version"):
|
|
455
|
+
result["differences"].append("schema_version")
|
|
456
|
+
if source_facts.get("tables") != target_facts.get("tables"):
|
|
457
|
+
result["differences"].append("tables")
|
|
458
|
+
if _table_counts(source) != _table_counts(target):
|
|
459
|
+
result["differences"].append("table_counts")
|
|
460
|
+
if project_scope_inventory(source) != project_scope_inventory(target):
|
|
461
|
+
result["differences"].append("project_scope")
|
|
462
|
+
result["equivalent"] = not result["differences"]
|
|
463
|
+
except (OSError, sqlite3.DatabaseError):
|
|
464
|
+
result["differences"].append("target_unreadable")
|
|
465
|
+
result["blocking_codes"].append("TARGET_UNREADABLE")
|
|
466
|
+
if not result["equivalent"] and not result["blocking_codes"]:
|
|
467
|
+
result["blocking_codes"].append("TARGET_STORE_CONFLICT")
|
|
468
|
+
return result
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _now() -> str:
|
|
472
|
+
return datetime.now(timezone.utc).isoformat()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _metadata(path: Path, key: str) -> dict[str, object] | None:
|
|
476
|
+
with sqlite3.connect(f"file:{path.resolve().as_posix()}?mode=ro", uri=True) as connection:
|
|
477
|
+
row = connection.execute("SELECT value FROM engineering_metadata WHERE key=?", (key,)).fetchone()
|
|
478
|
+
if row is None:
|
|
479
|
+
return None
|
|
480
|
+
value = json.loads(str(row[0]))
|
|
481
|
+
if not isinstance(value, dict):
|
|
482
|
+
raise CutoverError("ADMISSION_FREEZE_FAILED", "metadata is malformed")
|
|
483
|
+
return value
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def admission_status(repo: Path) -> dict[str, object]:
|
|
487
|
+
try:
|
|
488
|
+
path = database_path(repo)
|
|
489
|
+
if not path.is_file():
|
|
490
|
+
raise CutoverError("ADMISSION_FREEZE_FAILED", "authority is unresolved")
|
|
491
|
+
payload = _metadata(path, CONTROL_KEY)
|
|
492
|
+
return payload or {"state": "INACTIVE"}
|
|
493
|
+
except (OSError, sqlite3.DatabaseError, EngineeringStorageError) as error:
|
|
494
|
+
raise CutoverError("ADMISSION_FREEZE_FAILED", "authority is unreadable") from error
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def set_admission_freeze(repo: Path, *, migration_id: str | None = None, reason: str, operator: str = "operator") -> dict[str, object]:
|
|
498
|
+
"""Explicit control-plane mutation; no prompt/provider path calls this."""
|
|
499
|
+
candidates = discover_legacy_stores(repo)
|
|
500
|
+
if len(candidates) != 1 or not reason.strip():
|
|
501
|
+
raise CutoverError("ADMISSION_FREEZE_FAILED")
|
|
502
|
+
migration_id = migration_id or str(uuid.uuid4())
|
|
503
|
+
active = admission_status(repo)
|
|
504
|
+
if active.get("state") == "ACTIVE" and active.get("migration_id") != migration_id:
|
|
505
|
+
raise CutoverError("ADMISSION_FREEZE_FAILED", "conflicting active migration")
|
|
506
|
+
path = Path(candidates[0].resolved_path)
|
|
507
|
+
payload = {"version": 1, "migration_id": migration_id, "state": "ACTIVE", "reason": reason.strip(), "operator": operator, "created_at": _now()}
|
|
508
|
+
with sqlite3.connect(path) as connection:
|
|
509
|
+
connection.execute("INSERT INTO engineering_metadata(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", (CONTROL_KEY, json.dumps(payload, sort_keys=True, separators=(",", ":"))))
|
|
510
|
+
# This identity documents the pre-stop source only. Service shutdown is
|
|
511
|
+
# allowed to write bounded lifecycle evidence, so it is never the copy
|
|
512
|
+
# baseline or a source-drift gate.
|
|
513
|
+
receipt = load_receipt(migration_id)
|
|
514
|
+
if receipt is None:
|
|
515
|
+
receipt = {
|
|
516
|
+
"receipt_version": 1,
|
|
517
|
+
"migration_id": migration_id,
|
|
518
|
+
"schema": EXPECTED_SCHEMA,
|
|
519
|
+
"operator": operator,
|
|
520
|
+
"legacy_path": str(path),
|
|
521
|
+
"pre_stop_source_identity": asdict(source_identity(candidates[0])),
|
|
522
|
+
"rollback_mode": "PRE_WRITE_DIRECT",
|
|
523
|
+
}
|
|
524
|
+
transition_receipt(receipt, "PRECHECK")
|
|
525
|
+
transition_receipt(receipt, "ADMISSION_FROZEN", admission_freeze=payload)
|
|
526
|
+
return payload
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def thaw_admission(repo: Path, *, migration_id: str, operator: str = "operator") -> dict[str, object]:
|
|
530
|
+
state = admission_status(repo)
|
|
531
|
+
if state.get("state") != "ACTIVE" or state.get("migration_id") != migration_id:
|
|
532
|
+
raise CutoverError("THAW_FAILED")
|
|
533
|
+
path = database_path(repo)
|
|
534
|
+
payload = {"version": 1, "migration_id": migration_id, "state": "INACTIVE", "operator": operator, "thawed_at": _now()}
|
|
535
|
+
with sqlite3.connect(path) as connection:
|
|
536
|
+
connection.execute("UPDATE engineering_metadata SET value=? WHERE key=?", (json.dumps(payload, sort_keys=True, separators=(",", ":")), CONTROL_KEY))
|
|
537
|
+
receipt = load_receipt(migration_id)
|
|
538
|
+
if receipt is not None and receipt.get("state") == "ABORTED_PRE_HANDOFF":
|
|
539
|
+
receipt["thaw"] = {"operator": operator, "timestamp": payload["thawed_at"], "state": "INACTIVE"}
|
|
540
|
+
_atomic_json(receipt_path(migration_id), receipt)
|
|
541
|
+
return payload
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _atomic_json(path: Path, payload: dict[str, object]) -> None:
|
|
545
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
546
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
547
|
+
try:
|
|
548
|
+
with temporary.open("x", encoding="utf-8") as handle:
|
|
549
|
+
json.dump(payload, handle, sort_keys=True, separators=(",", ":"))
|
|
550
|
+
handle.flush()
|
|
551
|
+
os.fsync(handle.fileno())
|
|
552
|
+
os.replace(temporary, path)
|
|
553
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
554
|
+
try:
|
|
555
|
+
os.fsync(directory)
|
|
556
|
+
finally:
|
|
557
|
+
os.close(directory)
|
|
558
|
+
finally:
|
|
559
|
+
if temporary.exists():
|
|
560
|
+
temporary.unlink()
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def receipt_path(migration_id: str) -> Path:
|
|
564
|
+
return installation_data_root() / "migration" / f"{migration_id}.json"
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def load_receipt(migration_id: str) -> dict[str, object] | None:
|
|
568
|
+
path = receipt_path(migration_id)
|
|
569
|
+
if not path.is_file():
|
|
570
|
+
return None
|
|
571
|
+
try:
|
|
572
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
573
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
574
|
+
raise CutoverError("AUTHORITY_SWITCH_FAILED", "receipt is malformed") from error
|
|
575
|
+
if not isinstance(payload, dict) or payload.get("migration_id") != migration_id:
|
|
576
|
+
raise CutoverError("AUTHORITY_SWITCH_FAILED", "receipt identity is invalid")
|
|
577
|
+
return payload
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def transition_receipt(receipt: dict[str, object], state: str, **details: object) -> dict[str, object]:
|
|
581
|
+
"""Persist only adjacent forward transitions; state cannot be skipped/backtracked."""
|
|
582
|
+
if state not in STATES:
|
|
583
|
+
raise CutoverError("AUTHORITY_SWITCH_FAILED", "unknown state")
|
|
584
|
+
prior = receipt.get("state")
|
|
585
|
+
if prior is not None:
|
|
586
|
+
recovery_next = {
|
|
587
|
+
"SERVICES_RESTARTED": "CONTAMINATED_RECOVERY_PRECHECK",
|
|
588
|
+
"CONTAMINATED_RECOVERY_PRECHECK": "RECOVERY_SERVICES_QUIESCED",
|
|
589
|
+
"RECOVERY_SERVICES_QUIESCED": "RECOVERY_LEGACY_VERIFIED",
|
|
590
|
+
"RECOVERY_LEGACY_VERIFIED": "ROLLBACK_IN_PROGRESS",
|
|
591
|
+
"ROLLBACK_IN_PROGRESS": "ROLLBACK_COMPLETED",
|
|
592
|
+
}
|
|
593
|
+
if recovery_next.get(str(prior)) == state:
|
|
594
|
+
receipt["state"] = state
|
|
595
|
+
receipt.setdefault("transitions", []).append({"state": state, "timestamp": _now()})
|
|
596
|
+
receipt.update(details)
|
|
597
|
+
_atomic_json(receipt_path(str(receipt["migration_id"])), receipt)
|
|
598
|
+
return receipt
|
|
599
|
+
if state == "ABORTED_PRE_HANDOFF":
|
|
600
|
+
if prior not in ABORTABLE_STATES:
|
|
601
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "migration is not pre-handoff")
|
|
602
|
+
else:
|
|
603
|
+
try:
|
|
604
|
+
expected = STATES[STATES.index(str(prior)) + 1]
|
|
605
|
+
except (ValueError, IndexError) as error:
|
|
606
|
+
raise CutoverError("AUTHORITY_SWITCH_FAILED", "terminal or invalid transition") from error
|
|
607
|
+
if state != expected:
|
|
608
|
+
raise CutoverError("AUTHORITY_SWITCH_FAILED", "non-monotonic transition")
|
|
609
|
+
receipt["state"] = state
|
|
610
|
+
receipt.setdefault("transitions", []).append({"state": state, "timestamp": _now()})
|
|
611
|
+
receipt.update(details)
|
|
612
|
+
_atomic_json(receipt_path(str(receipt["migration_id"])), receipt)
|
|
613
|
+
return receipt
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _central_write_assessment(path: Path) -> dict[str, object]:
|
|
617
|
+
"""Read only provenance assessment; unknown lineage blocks recovery."""
|
|
618
|
+
result: dict[str, object] = {"legitimate_write": False, "managed_legitimate_count": 0, "proven_contamination_count": 0, "unresolved_count": 0, "unknown_mutation": False, "signals": []}
|
|
619
|
+
try:
|
|
620
|
+
with _readonly(path) as connection:
|
|
621
|
+
tables = _tables(connection)
|
|
622
|
+
if "provider_invocation_receipts" in tables and _count(connection, "SELECT COUNT(*) FROM provider_invocation_receipts"):
|
|
623
|
+
# Receipts are evidence of a provider launch, not proof that its
|
|
624
|
+
# source was production. Without a canonical submission lineage
|
|
625
|
+
# they must be explained by the contamination attestation below.
|
|
626
|
+
result["signals"].append("provider_receipts_without_submission_lineage")
|
|
627
|
+
if "backup_probe" in tables:
|
|
628
|
+
result["signals"].append("test_only_backup_probe")
|
|
629
|
+
schema = _schema(connection, tables)
|
|
630
|
+
if schema is not None and schema > EXPECTED_SCHEMA:
|
|
631
|
+
result["signals"].append("unsupported_schema_marker")
|
|
632
|
+
result["proven_contamination_count"] = len([signal for signal in result["signals"] if signal in {"test_only_backup_probe", "unsupported_schema_marker"}])
|
|
633
|
+
except (OSError, sqlite3.DatabaseError) as error:
|
|
634
|
+
raise CutoverError("FORENSIC_CENTRAL_UNREADABLE") from error
|
|
635
|
+
return result
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _domain_digest(path: Path, table: str, columns: tuple[str, ...]) -> str:
|
|
639
|
+
"""Hash a sorted, type-stable authority projection without secrets."""
|
|
640
|
+
with _readonly(path) as connection:
|
|
641
|
+
available = {str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})")}
|
|
642
|
+
if not set(columns) <= available:
|
|
643
|
+
return "ABSENT"
|
|
644
|
+
rows = connection.execute(
|
|
645
|
+
f"SELECT {','.join(columns)} FROM {table} ORDER BY {','.join(columns)}"
|
|
646
|
+
).fetchall()
|
|
647
|
+
normalized = []
|
|
648
|
+
for row in rows:
|
|
649
|
+
normalized.append([value.hex() if isinstance(value, bytes) else value for value in row])
|
|
650
|
+
return hashlib.sha256(json.dumps(normalized, ensure_ascii=False, separators=(",", ":"), sort_keys=False).encode("utf-8")).hexdigest()
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def authority_independent_baseline_attestation(baseline: Path, central: Path) -> dict[str, object]:
|
|
654
|
+
"""ADR-0025 exact baseline-delta classifier; never invents row origin."""
|
|
655
|
+
baseline_pair = _consumer_authority_tables_for_path(baseline)
|
|
656
|
+
central_pair = _consumer_authority_tables_for_path(central)
|
|
657
|
+
if baseline_pair is None or central_pair is None:
|
|
658
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "consumer authority tables are unavailable")
|
|
659
|
+
domains = {
|
|
660
|
+
"credentials": (baseline_pair[0], central_pair[0], ("credential_id", "consumer_id", "project_id", "verifier", "fingerprint", "issued_at", "expires_at", "revoked_at", "replaced_by_credential_id")),
|
|
661
|
+
"registrations": (baseline_pair[1], central_pair[1], ("consumer_id", "project_id", "status", "created_at", "updated_at", "disabled_at", "revoked_at")),
|
|
662
|
+
"project_scope": (baseline_pair[1], central_pair[1], ("consumer_id", "project_id", "status")),
|
|
663
|
+
}
|
|
664
|
+
result: dict[str, object] = {"attestation_version": 1, "domains": {}, "credential_delta": False, "registration_delta": False, "project_scope_delta": False}
|
|
665
|
+
for name, (baseline_table, central_table, columns) in domains.items():
|
|
666
|
+
baseline_digest, central_digest = _domain_digest(baseline, baseline_table, columns), _domain_digest(central, central_table, columns)
|
|
667
|
+
delta = baseline_digest != central_digest
|
|
668
|
+
result["domains"][name] = {"baseline_digest": baseline_digest, "central_digest": central_digest, "classification": "CONTAMINATION_PROVENANCE_UNRESOLVED" if delta else "NO_POST_CUTOVER_MUTATION"}
|
|
669
|
+
result[f"{name[:-1] if name.endswith('s') else name}_delta"] = delta
|
|
670
|
+
return result
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _file_digest(path: Path) -> str:
|
|
674
|
+
try:
|
|
675
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
676
|
+
except OSError as error:
|
|
677
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "required forensic file is unreadable") from error
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def contamination_attestation_path(migration_id: str) -> Path:
|
|
681
|
+
"""Return the external, immutable receipt path for one incident only."""
|
|
682
|
+
return installation_data_root() / "migration" / "contamination-attestations" / f"{migration_id}.json"
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _authority_rows(path: Path, table: str, columns: tuple[str, ...], key: tuple[str, ...]) -> dict[tuple[object, ...], dict[str, object]]:
|
|
686
|
+
with _readonly(path) as connection:
|
|
687
|
+
available = {str(row[1]) for row in connection.execute(f"PRAGMA table_info({_quote_identifier(table)})")}
|
|
688
|
+
if not set(columns) <= available:
|
|
689
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", f"authority table shape is unavailable: {table}")
|
|
690
|
+
rows = connection.execute(f"SELECT {','.join(_quote_identifier(column) for column in columns)} FROM {_quote_identifier(table)}").fetchall()
|
|
691
|
+
result: dict[tuple[object, ...], dict[str, object]] = {}
|
|
692
|
+
for values in rows:
|
|
693
|
+
row = dict(zip(columns, values, strict=True))
|
|
694
|
+
identity = tuple(row[column] for column in key)
|
|
695
|
+
if identity in result:
|
|
696
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", f"non-unique authority identity: {table}")
|
|
697
|
+
result[identity] = row
|
|
698
|
+
return result
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _safe_authority_row(row: dict[str, object]) -> dict[str, object]:
|
|
702
|
+
"""Project authority evidence without retaining verifier or fingerprint bytes."""
|
|
703
|
+
safe: dict[str, object] = {}
|
|
704
|
+
for key, value in sorted(row.items()):
|
|
705
|
+
if key in {"verifier", "fingerprint", "audit_metadata"}:
|
|
706
|
+
if value is None:
|
|
707
|
+
safe[f"{key}_digest"] = None
|
|
708
|
+
elif isinstance(value, bytes):
|
|
709
|
+
safe[f"{key}_digest"] = hashlib.sha256(value).hexdigest()
|
|
710
|
+
else:
|
|
711
|
+
safe[f"{key}_digest"] = hashlib.sha256(str(value).encode("utf-8")).hexdigest()
|
|
712
|
+
else:
|
|
713
|
+
safe[key] = value
|
|
714
|
+
return safe
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _historical_fixture_components(baseline: Path, central: Path) -> list[dict[str, object]]:
|
|
718
|
+
"""Mechanically recognize only the four authorized historical test subjects."""
|
|
719
|
+
credentials = ("credential_id", "consumer_id", "project_id", "verifier", "fingerprint", "issued_at", "expires_at", "revoked_at", "replaced_by_credential_id")
|
|
720
|
+
registrations = ("consumer_id", "project_id", "status", "created_at", "updated_at", "disabled_at", "revoked_at", "audit_metadata")
|
|
721
|
+
baseline_pair = _consumer_authority_tables_for_path(baseline)
|
|
722
|
+
central_pair = _consumer_authority_tables_for_path(central)
|
|
723
|
+
if baseline_pair is None or central_pair is None:
|
|
724
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "consumer authority tables are unavailable")
|
|
725
|
+
old_credentials = _authority_rows(baseline, baseline_pair[0], credentials, ("credential_id",))
|
|
726
|
+
new_credentials = _authority_rows(central, central_pair[0], credentials, ("credential_id",))
|
|
727
|
+
old_registrations = _authority_rows(baseline, baseline_pair[1], registrations, ("consumer_id", "project_id"))
|
|
728
|
+
new_registrations = _authority_rows(central, central_pair[1], registrations, ("consumer_id", "project_id"))
|
|
729
|
+
if any(old_credentials[key] != new_credentials.get(key) for key in old_credentials) or any(old_registrations[key] != new_registrations.get(key) for key in old_registrations):
|
|
730
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "baseline authority rows changed")
|
|
731
|
+
if set(new_credentials) - set(old_credentials) and not set(old_credentials) <= set(new_credentials):
|
|
732
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "credential removal")
|
|
733
|
+
if set(new_registrations) - set(old_registrations) and not set(old_registrations) <= set(new_registrations):
|
|
734
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "registration removal")
|
|
735
|
+
added_credentials = [row for key, row in new_credentials.items() if key not in old_credentials]
|
|
736
|
+
added_registrations = [row for key, row in new_registrations.items() if key not in old_registrations]
|
|
737
|
+
groups: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = {}
|
|
738
|
+
for row in added_credentials:
|
|
739
|
+
groups.setdefault((str(row["consumer_id"]), str(row["project_id"])), {"credentials": [], "registrations": []})["credentials"].append(row)
|
|
740
|
+
for row in added_registrations:
|
|
741
|
+
groups.setdefault((str(row["consumer_id"]), str(row["project_id"])), {"credentials": [], "registrations": []})["registrations"].append(row)
|
|
742
|
+
expected = {
|
|
743
|
+
("workspace-client", "project-alpha"), ("consumer", "project"),
|
|
744
|
+
("rotate", "project"), ("qualification-client", "qualification-project"),
|
|
745
|
+
}
|
|
746
|
+
if set(groups) != expected:
|
|
747
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "authority components are not the authorized historical fixture set")
|
|
748
|
+
components: list[dict[str, object]] = []
|
|
749
|
+
for subject in sorted(groups):
|
|
750
|
+
consumer_id, project_id = subject
|
|
751
|
+
values = groups[subject]
|
|
752
|
+
credentials_for_subject = sorted(values["credentials"], key=lambda row: str(row["credential_id"]))
|
|
753
|
+
registrations_for_subject = values["registrations"]
|
|
754
|
+
signals: list[str]
|
|
755
|
+
writer: str
|
|
756
|
+
if subject == ("workspace-client", "project-alpha"):
|
|
757
|
+
credential = credentials_for_subject[0] if len(credentials_for_subject) == 1 else None
|
|
758
|
+
if (credential is None or len(registrations_for_subject) != 1
|
|
759
|
+
or credential["credential_id"] != "credential-alpha" or credential["issued_at"] != "now"
|
|
760
|
+
or not isinstance(credential["verifier"], bytes) or len(credential["verifier"]) != 32
|
|
761
|
+
or not isinstance(credential["fingerprint"], bytes) or len(credential["fingerprint"]) != 32):
|
|
762
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "workspace fixture mismatch")
|
|
763
|
+
signals, writer = ["exact_fixture_literals", "fixture_blob_shape"], "tests/engineering/test_central_store_migration.py"
|
|
764
|
+
elif subject == ("consumer", "project"):
|
|
765
|
+
credential = credentials_for_subject[0] if len(credentials_for_subject) == 1 else None
|
|
766
|
+
registration = registrations_for_subject[0] if len(registrations_for_subject) == 1 else None
|
|
767
|
+
if (credential is None or registration is None or credential["credential_id"] != "production-consumer"
|
|
768
|
+
or registration["status"] != "DISABLED" or credential["revoked_at"] is None
|
|
769
|
+
or registration["disabled_at"] is None or registration["audit_metadata"] != '{"action":"DISABLE"}'):
|
|
770
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "consumer fixture mismatch")
|
|
771
|
+
signals, writer = ["exact_fixture_literals", "disable_revoke_lifecycle"], "tests/engineering/test_local_api_consumer_credentials.py"
|
|
772
|
+
elif subject == ("rotate", "project"):
|
|
773
|
+
credential_ids = {str(row["credential_id"]) for row in credentials_for_subject}
|
|
774
|
+
if (len(credentials_for_subject) != 2 or len(registrations_for_subject) != 1
|
|
775
|
+
or credential_ids != {"production-rotate-old", "production-rotate-new"}
|
|
776
|
+
or registrations_for_subject[0]["status"] != "ACTIVE"
|
|
777
|
+
or sum(row["revoked_at"] is None for row in credentials_for_subject) != 1):
|
|
778
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "rotation fixture mismatch")
|
|
779
|
+
signals, writer = ["exact_fixture_literals", "rotation_lifecycle"], "tests/engineering/test_local_api_consumer_credentials.py"
|
|
780
|
+
else:
|
|
781
|
+
credential = credentials_for_subject[0] if len(credentials_for_subject) == 1 else None
|
|
782
|
+
try:
|
|
783
|
+
issued = datetime.strptime(str(credential["issued_at"]), "%Y-%m-%d %H:%M:%S") if credential else None
|
|
784
|
+
expires = datetime.strptime(str(credential["expires_at"]), "%Y-%m-%d %H:%M:%S") if credential else None
|
|
785
|
+
except ValueError as error:
|
|
786
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "qualification fixture timestamp mismatch") from error
|
|
787
|
+
if (credential is None or registrations_for_subject or credential["credential_id"] != "qualification-fixture"
|
|
788
|
+
or issued is None or expires is None or expires - issued != timedelta(minutes=15)):
|
|
789
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "qualification fixture mismatch")
|
|
790
|
+
signals, writer = ["exact_fixture_literals", "qualification_prefix_and_ttl"], "tests/engineering/test_local_api_qualification_credentials.py"
|
|
791
|
+
components.append({"consumer_id": consumer_id, "project_id": project_id, "credentials": [_safe_authority_row(row) for row in credentials_for_subject], "registrations": [_safe_authority_row(row) for row in registrations_for_subject], "test_writer": writer, "signals": signals})
|
|
792
|
+
return components
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _component_digest(components: list[dict[str, object]]) -> str:
|
|
796
|
+
return hashlib.sha256(json.dumps(components, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")).hexdigest()
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def create_contamination_attestation(repo: Path, *, migration_id: str, operator: str) -> dict[str, object]:
|
|
800
|
+
"""Persist one operator-owned, immutable external attestation for this incident."""
|
|
801
|
+
receipt = load_receipt(migration_id)
|
|
802
|
+
if receipt is None or receipt.get("state") != "SERVICES_RESTARTED" or not operator:
|
|
803
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "eligible incident state and operator are required")
|
|
804
|
+
pointer_path = authority_pointer_path()
|
|
805
|
+
try:
|
|
806
|
+
pointer = json.loads(pointer_path.read_text(encoding="utf-8"))
|
|
807
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
808
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "authority pointer is unreadable") from error
|
|
809
|
+
legacy, central = Path(str(receipt.get("legacy_path", ""))), Path(str(pointer.get("authoritative_path", "")))
|
|
810
|
+
baseline = receipt.get("quiescent_source_baseline")
|
|
811
|
+
source = baseline.get("source") if isinstance(baseline, dict) else None
|
|
812
|
+
if not isinstance(source, dict) or _fingerprint(legacy) != source.get("fingerprint_sha256"):
|
|
813
|
+
raise CutoverError("LEGACY_BASELINE_MISMATCH")
|
|
814
|
+
components = _historical_fixture_components(legacy, central)
|
|
815
|
+
if any(len(component["signals"]) < 2 for component in components):
|
|
816
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "weak fixture evidence")
|
|
817
|
+
with _readonly(central) as connection:
|
|
818
|
+
schema = _schema(connection, _tables(connection))
|
|
819
|
+
binding = {"migration_id": migration_id, "legacy_fingerprint_sha256": _fingerprint(legacy), "central_fingerprint_sha256": _fingerprint(central), "authority_pointer_fingerprint_sha256": _file_digest(pointer_path), "central_schema": schema, "delta_digest": _component_digest(components)}
|
|
820
|
+
payload = {"attestation_id": f"historical-contamination-{migration_id}", "attestation_version": HISTORICAL_ATTESTATION_VERSION, "classifier_version": HISTORICAL_ATTESTATION_CLASSIFIER_VERSION, "origin_class": "OPERATOR_FORENSIC_CONTROL", "operator": operator, "created_at": _now(), "binding": binding, "components": components, "evidence_strength": "TWO_OR_MORE_DETERMINISTIC_SIGNALS_PER_COMPONENT", "eligibility": "PROVEN_NON_PRODUCTION_CONTAMINATION"}
|
|
821
|
+
path = contamination_attestation_path(migration_id)
|
|
822
|
+
if path.exists():
|
|
823
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "attestation is immutable and already exists")
|
|
824
|
+
_atomic_json(path, payload)
|
|
825
|
+
return payload
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _valid_contamination_attestation(repo: Path, *, migration_id: str, legacy: Path, central: Path, pointer_path: Path) -> dict[str, object] | None:
|
|
829
|
+
path = contamination_attestation_path(migration_id)
|
|
830
|
+
try:
|
|
831
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
832
|
+
except (OSError, json.JSONDecodeError):
|
|
833
|
+
return None
|
|
834
|
+
if not isinstance(payload, dict) or payload.get("attestation_version") != HISTORICAL_ATTESTATION_VERSION or payload.get("classifier_version") != HISTORICAL_ATTESTATION_CLASSIFIER_VERSION:
|
|
835
|
+
return None
|
|
836
|
+
try:
|
|
837
|
+
components = _historical_fixture_components(legacy, central)
|
|
838
|
+
with _readonly(central) as connection:
|
|
839
|
+
schema = _schema(connection, _tables(connection))
|
|
840
|
+
expected = {"migration_id": migration_id, "legacy_fingerprint_sha256": _fingerprint(legacy), "central_fingerprint_sha256": _fingerprint(central), "authority_pointer_fingerprint_sha256": _file_digest(pointer_path), "central_schema": schema, "delta_digest": _component_digest(components)}
|
|
841
|
+
except CutoverError:
|
|
842
|
+
return None
|
|
843
|
+
return payload if payload.get("binding") == expected and payload.get("components") == components else None
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _quote_identifier(value: str) -> str:
|
|
847
|
+
return '"' + value.replace('"', '""') + '"'
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _normalized_value(value: object) -> object:
|
|
851
|
+
"""Return a type-stable, deterministic representation of a SQLite value."""
|
|
852
|
+
if value is None:
|
|
853
|
+
return {"type": "null"}
|
|
854
|
+
if isinstance(value, bytes):
|
|
855
|
+
return {"type": "bytes", "hex": value.hex()}
|
|
856
|
+
if isinstance(value, str):
|
|
857
|
+
text = unicodedata.normalize("NFC", value)
|
|
858
|
+
try:
|
|
859
|
+
parsed = json.loads(text)
|
|
860
|
+
except json.JSONDecodeError:
|
|
861
|
+
return {"type": "text", "value": text}
|
|
862
|
+
if isinstance(parsed, (dict, list)):
|
|
863
|
+
return {"type": "json", "value": parsed}
|
|
864
|
+
return {"type": "text", "value": text}
|
|
865
|
+
if isinstance(value, bool):
|
|
866
|
+
return {"type": "boolean", "value": value}
|
|
867
|
+
if isinstance(value, int):
|
|
868
|
+
return {"type": "integer", "value": value}
|
|
869
|
+
if isinstance(value, float):
|
|
870
|
+
return {"type": "real", "value": value}
|
|
871
|
+
return {"type": type(value).__name__, "value": str(value)}
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _table_key(connection: sqlite3.Connection, table: str) -> tuple[str, ...]:
|
|
875
|
+
"""Resolve a declared primary key or non-partial enforced unique key."""
|
|
876
|
+
quoted = _quote_identifier(table)
|
|
877
|
+
columns = connection.execute(f"PRAGMA table_info({quoted})").fetchall()
|
|
878
|
+
primary = tuple(str(row[1]) for row in sorted(columns, key=lambda row: int(row[5])) if int(row[5]))
|
|
879
|
+
if primary:
|
|
880
|
+
return primary
|
|
881
|
+
candidates: list[tuple[str, ...]] = []
|
|
882
|
+
for index in connection.execute(f"PRAGMA index_list({quoted})"):
|
|
883
|
+
# seq, name, unique, origin, partial
|
|
884
|
+
if not int(index[2]) or (len(index) > 4 and int(index[4])):
|
|
885
|
+
continue
|
|
886
|
+
index_name = _quote_identifier(str(index[1]))
|
|
887
|
+
key = tuple(str(row[2]) for row in connection.execute(f"PRAGMA index_info({index_name})"))
|
|
888
|
+
if key:
|
|
889
|
+
candidates.append(key)
|
|
890
|
+
if candidates:
|
|
891
|
+
return min(candidates, key=lambda key: (len(key), key))
|
|
892
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", f"run-bound table has no deterministic key: {table}")
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _row_map(connection: sqlite3.Connection, table: str, key: tuple[str, ...]) -> dict[str, dict[str, object]]:
|
|
896
|
+
quoted = _quote_identifier(table)
|
|
897
|
+
columns = tuple(str(row[1]) for row in connection.execute(f"PRAGMA table_info({quoted})"))
|
|
898
|
+
rows = connection.execute(f"SELECT * FROM {quoted}").fetchall()
|
|
899
|
+
result: dict[str, dict[str, object]] = {}
|
|
900
|
+
for row in rows:
|
|
901
|
+
raw = dict(zip(columns, row, strict=True))
|
|
902
|
+
key_value = [_normalized_value(raw[column]) for column in key]
|
|
903
|
+
encoded_key = json.dumps(key_value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
904
|
+
if encoded_key in result:
|
|
905
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", f"non-unique deterministic key: {table}")
|
|
906
|
+
result[encoded_key] = {column: _normalized_value(raw[column]) for column in sorted(raw)}
|
|
907
|
+
return result
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _run_bound_tables(connection: sqlite3.Connection) -> tuple[str, ...]:
|
|
911
|
+
tables = _tables(connection)
|
|
912
|
+
result = []
|
|
913
|
+
for table in tables:
|
|
914
|
+
columns = {str(row[1]) for row in connection.execute(f"PRAGMA table_info({_quote_identifier(table)})")}
|
|
915
|
+
if "run_id" in columns or (table == "execution_submissions" and "execution_run_id" in columns):
|
|
916
|
+
result.append(table)
|
|
917
|
+
return tuple(sorted(result))
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _row_deltas(baseline: Path, central: Path) -> dict[str, list[dict[str, object]]]:
|
|
921
|
+
"""Compare all run-bound evidence rows by their declared stable identity."""
|
|
922
|
+
with _readonly(baseline) as baseline_connection, _readonly(central) as central_connection:
|
|
923
|
+
tables = set(_run_bound_tables(baseline_connection)) | set(_run_bound_tables(central_connection))
|
|
924
|
+
deltas: dict[str, list[dict[str, object]]] = {}
|
|
925
|
+
for table in sorted(tables):
|
|
926
|
+
baseline_present = table in _tables(baseline_connection)
|
|
927
|
+
central_present = table in _tables(central_connection)
|
|
928
|
+
key_connection = central_connection if central_present else baseline_connection
|
|
929
|
+
key = _table_key(key_connection, table)
|
|
930
|
+
baseline_rows = _row_map(baseline_connection, table, key) if baseline_present else {}
|
|
931
|
+
central_rows = _row_map(central_connection, table, key) if central_present else {}
|
|
932
|
+
changes: list[dict[str, object]] = []
|
|
933
|
+
for encoded_key in sorted(set(baseline_rows) | set(central_rows)):
|
|
934
|
+
before, after = baseline_rows.get(encoded_key), central_rows.get(encoded_key)
|
|
935
|
+
if before is None:
|
|
936
|
+
changes.append({"change_type": "ADDED", "key": json.loads(encoded_key), "row": after})
|
|
937
|
+
elif after is None:
|
|
938
|
+
changes.append({"change_type": "REMOVED", "key": json.loads(encoded_key), "row": before})
|
|
939
|
+
elif before != after:
|
|
940
|
+
changes.append({"change_type": "MODIFIED", "key": json.loads(encoded_key), "before": before, "after": after})
|
|
941
|
+
if changes:
|
|
942
|
+
deltas[table] = changes
|
|
943
|
+
return deltas
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _plain_value(value: object) -> object:
|
|
947
|
+
return value.get("value") if isinstance(value, dict) and "value" in value else None
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _row_text(row: object, column: str) -> str | None:
|
|
951
|
+
value = _plain_value(row.get(column)) if isinstance(row, dict) else None
|
|
952
|
+
return value if isinstance(value, str) and value else None
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _changed_run_ids(table: str, changes: list[dict[str, object]]) -> set[str]:
|
|
956
|
+
run_column = "execution_run_id" if table == "execution_submissions" else "run_id"
|
|
957
|
+
result: set[str] = set()
|
|
958
|
+
for change in changes:
|
|
959
|
+
rows = [change.get("row"), change.get("before"), change.get("after")]
|
|
960
|
+
for row in rows:
|
|
961
|
+
if isinstance(row, dict):
|
|
962
|
+
value = _plain_value(row.get(run_column))
|
|
963
|
+
if isinstance(value, str) and value:
|
|
964
|
+
result.add(value)
|
|
965
|
+
return result
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _lineage_category(table: str, change: dict[str, object]) -> str:
|
|
969
|
+
if table == "provider_recovery_attempts":
|
|
970
|
+
return "recovery"
|
|
971
|
+
if "provider" in table:
|
|
972
|
+
return "provider"
|
|
973
|
+
if "validation" in table:
|
|
974
|
+
return "validation"
|
|
975
|
+
if "qualification" in table:
|
|
976
|
+
return "qualification"
|
|
977
|
+
if table == "prompt_execution_history":
|
|
978
|
+
return "prompt_history"
|
|
979
|
+
if "reconciliation" in table:
|
|
980
|
+
return "reconciliation"
|
|
981
|
+
if table == "engineering_transactions":
|
|
982
|
+
rows = (change.get("row"), change.get("before"), change.get("after"))
|
|
983
|
+
for row in rows:
|
|
984
|
+
payload = _plain_value(row.get("payload")) if isinstance(row, dict) else None
|
|
985
|
+
if isinstance(payload, dict) and any("FINAL" in str(value).upper() for value in payload.values()):
|
|
986
|
+
return "finalization"
|
|
987
|
+
return "implementation"
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def managed_lineage_attestation(baseline: Path, central: Path) -> dict[str, object]:
|
|
991
|
+
"""Classify only row-level post-baseline nodes by their persisted roots."""
|
|
992
|
+
deltas = _row_deltas(baseline, central)
|
|
993
|
+
categories = {name: 0 for name in ("provider", "recovery", "validation", "qualification", "implementation", "finalization", "reconciliation", "prompt_history")}
|
|
994
|
+
changed_runs: dict[str, set[str]] = {}
|
|
995
|
+
for table, changes in deltas.items():
|
|
996
|
+
runs = _changed_run_ids(table, changes)
|
|
997
|
+
if runs:
|
|
998
|
+
changed_runs[table] = runs
|
|
999
|
+
for change in changes:
|
|
1000
|
+
categories[_lineage_category(table, change)] += 1
|
|
1001
|
+
with _readonly(central) as connection:
|
|
1002
|
+
tables = _tables(connection)
|
|
1003
|
+
producers: dict[str, set[str]] = {}
|
|
1004
|
+
if "execution_submissions" in tables:
|
|
1005
|
+
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(execution_submissions)")}
|
|
1006
|
+
if {"execution_run_id", "producer_type"} <= columns:
|
|
1007
|
+
for run_id, producer_type in connection.execute("SELECT execution_run_id,producer_type FROM execution_submissions WHERE execution_run_id IS NOT NULL"):
|
|
1008
|
+
producers.setdefault(str(run_id), set()).add(str(producer_type))
|
|
1009
|
+
if "execution_submission_links" in tables and "execution_submissions" in tables:
|
|
1010
|
+
for run_id, producer_type in connection.execute("SELECT link.run_id,submission.producer_type FROM execution_submission_links AS link JOIN execution_submissions AS submission ON submission.submission_id=link.submission_id"):
|
|
1011
|
+
producers.setdefault(str(run_id), set()).add(str(producer_type))
|
|
1012
|
+
nodes = {(table, run_id) for table, runs in changed_runs.items() for run_id in runs}
|
|
1013
|
+
# A new submission is itself a canonical root even before a run link has
|
|
1014
|
+
# been written. Treating it as invisible would permit a real post-cutover
|
|
1015
|
+
# human submission to evade the recovery gate during its earliest phase.
|
|
1016
|
+
submission_origins: dict[str, set[str]] = {}
|
|
1017
|
+
for change in deltas.get("execution_submissions", []):
|
|
1018
|
+
for row in (change.get("row"), change.get("before"), change.get("after")):
|
|
1019
|
+
submission_id, producer_type = _row_text(row, "submission_id"), _row_text(row, "producer_type")
|
|
1020
|
+
execution_run_id = _row_text(row, "execution_run_id")
|
|
1021
|
+
if submission_id and producer_type and execution_run_id is None:
|
|
1022
|
+
node = ("execution_submissions", f"submission:{submission_id}")
|
|
1023
|
+
nodes.add(node)
|
|
1024
|
+
submission_origins.setdefault(node[1], set()).add(producer_type)
|
|
1025
|
+
production_origins = {"HUMAN", "MANAGED", "ICLOUD", "HUMAN_OPERATOR"}
|
|
1026
|
+
test_origins = {"TEST_HARNESS"}
|
|
1027
|
+
def origins(node: tuple[str, str]) -> set[str]:
|
|
1028
|
+
return submission_origins.get(node[1], producers.get(node[1], set()))
|
|
1029
|
+
|
|
1030
|
+
production = {node for node in nodes if origins(node) & production_origins}
|
|
1031
|
+
test = {node for node in nodes if not (origins(node) & production_origins) and origins(node) & test_origins}
|
|
1032
|
+
unresolved = nodes - production - test
|
|
1033
|
+
|
|
1034
|
+
def components(node_set: set[tuple[str, str]]) -> set[str]:
|
|
1035
|
+
return {node[1] for node in node_set}
|
|
1036
|
+
return {
|
|
1037
|
+
"row_delta_version": 1,
|
|
1038
|
+
"changed_rows": {table: len(changes) for table, changes in deltas.items()},
|
|
1039
|
+
"changed_run_nodes": {table: sorted(runs) for table, runs in changed_runs.items()},
|
|
1040
|
+
"production_component_count": len(components(production)),
|
|
1041
|
+
"production_node_count": len(production),
|
|
1042
|
+
"unresolved_component_count": len(components(unresolved)),
|
|
1043
|
+
"unresolved_node_count": len(unresolved),
|
|
1044
|
+
"test_component_count": len(components(test)),
|
|
1045
|
+
"test_node_count": len(test),
|
|
1046
|
+
"categories": categories,
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def contaminated_prewrite_status(repo: Path, *, migration_id: str) -> dict[str, object]:
|
|
1051
|
+
"""Read-only eligibility for the narrowly bounded forensic recovery."""
|
|
1052
|
+
receipt = load_receipt(migration_id)
|
|
1053
|
+
if receipt is None or receipt.get("state") not in {"SERVICES_RESTARTED", "ROLLBACK_COMPLETED"}:
|
|
1054
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "legal predecessor is SERVICES_RESTARTED")
|
|
1055
|
+
freeze = admission_status(repo)
|
|
1056
|
+
if freeze.get("state") != "ACTIVE" or freeze.get("migration_id") != migration_id:
|
|
1057
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "matching active freeze is required")
|
|
1058
|
+
pointer_path = authority_pointer_path()
|
|
1059
|
+
try:
|
|
1060
|
+
pointer = json.loads(pointer_path.read_text(encoding="utf-8"))
|
|
1061
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
1062
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "central pointer is required") from error
|
|
1063
|
+
central = Path(str(pointer.get("authoritative_path", "")))
|
|
1064
|
+
legacy = Path(str(receipt.get("legacy_path", "")))
|
|
1065
|
+
if receipt.get("state") == "ROLLBACK_COMPLETED":
|
|
1066
|
+
return {"eligible": True, "idempotent": True, "state": "ROLLBACK_COMPLETED", "freeze": freeze, "authority": "LEGACY"}
|
|
1067
|
+
if pointer.get("migration_id") != migration_id or not central.is_file() or database_path(repo).resolve() != central.resolve():
|
|
1068
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "central authority does not match migration")
|
|
1069
|
+
assessment = _central_write_assessment(central)
|
|
1070
|
+
if assessment["legitimate_write"]:
|
|
1071
|
+
raise CutoverError("LEGITIMATE_CENTRAL_WRITE_PRESENT")
|
|
1072
|
+
with _readonly(central) as central_connection:
|
|
1073
|
+
tables = _tables(central_connection)
|
|
1074
|
+
central_schema = _schema(central_connection, tables)
|
|
1075
|
+
provenance = "PROVEN_NON_PRODUCTION" if {"test_only_backup_probe", "unsupported_schema_marker"} & set(assessment["signals"]) and not assessment["unknown_mutation"] else "UNRESOLVED"
|
|
1076
|
+
if provenance != "PROVEN_NON_PRODUCTION":
|
|
1077
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED")
|
|
1078
|
+
baseline = receipt.get("quiescent_source_baseline")
|
|
1079
|
+
source = baseline.get("source") if isinstance(baseline, dict) else receipt.get("source")
|
|
1080
|
+
if not legacy.is_file() or not isinstance(source, dict) or _fingerprint(legacy) != source.get("fingerprint_sha256"):
|
|
1081
|
+
raise CutoverError("LEGACY_BASELINE_MISMATCH")
|
|
1082
|
+
# Production and unresolved managed descendants always take precedence:
|
|
1083
|
+
# an historical attestation may explain only the bounded authority rows.
|
|
1084
|
+
lineage = managed_lineage_attestation(legacy, central)
|
|
1085
|
+
if lineage["production_node_count"]:
|
|
1086
|
+
raise CutoverError("LEGITIMATE_CENTRAL_WRITE_PRESENT")
|
|
1087
|
+
if lineage["unresolved_node_count"]:
|
|
1088
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "orphan managed evidence")
|
|
1089
|
+
domain_attestation = authority_independent_baseline_attestation(legacy, central)
|
|
1090
|
+
historical_attestation = _valid_contamination_attestation(
|
|
1091
|
+
repo, migration_id=migration_id, legacy=legacy, central=central, pointer_path=pointer_path,
|
|
1092
|
+
)
|
|
1093
|
+
if any((domain_attestation["credential_delta"], domain_attestation["registration_delta"], domain_attestation["project_scope_delta"])) and historical_attestation is None:
|
|
1094
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED", "authority-independent baseline delta")
|
|
1095
|
+
facts = inspect_source(StoreCandidate(str(central), str(central.resolve()), ("forensic_central",)))
|
|
1096
|
+
if facts.get("integrity") != "PASS":
|
|
1097
|
+
raise CutoverError("FORENSIC_CENTRAL_UNREADABLE")
|
|
1098
|
+
return {"eligible": True, "idempotent": False, "authority": "CENTRAL", "freeze": freeze, "central": {"path": str(central.resolve()), "fingerprint_sha256": _fingerprint(central), "schema": central_schema, "integrity": facts["integrity"], "critical_counts": _table_counts(central)}, "legacy": {"path": str(legacy.resolve()), "fingerprint_sha256": _fingerprint(legacy), "critical_counts": _table_counts(legacy), "project_scope": project_scope_inventory(legacy)}, "legitimate_write_assessment": assessment, "managed_lineage": lineage, "authority_independent_baseline": domain_attestation, "historical_contamination_attestation": historical_attestation, "contamination_provenance": provenance, "forensic_tables": sorted(tables)}
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def recover_contaminated_prewrite(repo: Path, *, migration_id: str, operator: str = "operator", services: LaunchAgentServiceControl | None = None) -> dict[str, object]:
|
|
1102
|
+
"""Operator-only CENTRAL-to-LEGACY forensic recovery; never copies data."""
|
|
1103
|
+
status = contaminated_prewrite_status(repo, migration_id=migration_id)
|
|
1104
|
+
if status["idempotent"]:
|
|
1105
|
+
return load_receipt(migration_id) or status
|
|
1106
|
+
receipt = load_receipt(migration_id)
|
|
1107
|
+
assert receipt is not None
|
|
1108
|
+
central = Path(str(status["central"]["path"]))
|
|
1109
|
+
legacy = Path(str(status["legacy"]["path"]))
|
|
1110
|
+
control = services or LaunchAgentServiceControl()
|
|
1111
|
+
transition_receipt(receipt, "CONTAMINATED_RECOVERY_PRECHECK", recovery_class="CONTAMINATED_PRE_WRITE_CENTRAL_RECOVERY", recovery_precheck=status, operator=operator)
|
|
1112
|
+
try:
|
|
1113
|
+
for label in SERVICE_STOP_ORDER:
|
|
1114
|
+
control.stop(label)
|
|
1115
|
+
if not control.stopped(label):
|
|
1116
|
+
raise CutoverError("RECOVERY_SERVICE_QUIESCENCE_FAILED", label)
|
|
1117
|
+
except CutoverError as error:
|
|
1118
|
+
raise CutoverError("RECOVERY_SERVICE_QUIESCENCE_FAILED", error.code) from error
|
|
1119
|
+
transition_receipt(receipt, "RECOVERY_SERVICES_QUIESCED", service_quiescence={label: control.stopped(label) for label in SERVICE_STOP_ORDER})
|
|
1120
|
+
if _fingerprint(legacy) != status["legacy"]["fingerprint_sha256"]:
|
|
1121
|
+
raise CutoverError("LEGACY_BASELINE_MISMATCH")
|
|
1122
|
+
transition_receipt(receipt, "RECOVERY_LEGACY_VERIFIED", legacy_baseline=status["legacy"])
|
|
1123
|
+
transition_receipt(receipt, "ROLLBACK_IN_PROGRESS")
|
|
1124
|
+
try:
|
|
1125
|
+
pointer = write_authority_pointer(migration_id=migration_id, authority=legacy, legacy=legacy, state="ROLLBACK_COMPLETED")
|
|
1126
|
+
except CutoverError as error:
|
|
1127
|
+
raise CutoverError("RECOVERY_AUTHORITY_SWITCH_FAILED") from error
|
|
1128
|
+
central_after = _fingerprint(central)
|
|
1129
|
+
if central_after != status["central"]["fingerprint_sha256"]:
|
|
1130
|
+
raise CutoverError("RECOVERY_POSTCHECK_FAILED", "central changed")
|
|
1131
|
+
try:
|
|
1132
|
+
for label in SERVICE_START_ORDER:
|
|
1133
|
+
control.start(label)
|
|
1134
|
+
binding = service_binding_proof(repo, expected=legacy, services=SERVICE_START_ORDER)
|
|
1135
|
+
except CutoverError as error:
|
|
1136
|
+
raise CutoverError("RECOVERY_SERVICE_RESTART_FAILED", error.code) from error
|
|
1137
|
+
if not binding.get("consistent") or not all(control.running(label) for label in SERVICE_START_ORDER):
|
|
1138
|
+
raise CutoverError("RECOVERY_MIXED_BINDING")
|
|
1139
|
+
freeze = admission_status(repo)
|
|
1140
|
+
if freeze.get("state") != "ACTIVE" or freeze.get("migration_id") != migration_id:
|
|
1141
|
+
raise CutoverError("RECOVERY_POSTCHECK_FAILED", "freeze changed")
|
|
1142
|
+
return transition_receipt(receipt, "ROLLBACK_COMPLETED", rollback={"operator": operator, "authority_pointer": pointer, "timestamp": _now()}, central_forensic={**status["central"], "classification": "FORENSIC_CONTAMINATED_NON_AUTHORITATIVE", "contamination_provenance": status["contamination_provenance"], "legitimate_write_assessment": status["legitimate_write_assessment"], "fingerprint_after": central_after}, post_recovery_binding=binding, freeze_after=freeze)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def abort_pre_handoff(
|
|
1146
|
+
repo: Path,
|
|
1147
|
+
*,
|
|
1148
|
+
migration_id: str,
|
|
1149
|
+
reason: str,
|
|
1150
|
+
operator: str = "operator",
|
|
1151
|
+
services: LaunchAgentServiceControl | None = None,
|
|
1152
|
+
) -> dict[str, object]:
|
|
1153
|
+
"""Retire one frozen migration before any backup, target, or authority handoff."""
|
|
1154
|
+
if reason not in ABORT_REASONS:
|
|
1155
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "reason is not allowed")
|
|
1156
|
+
try:
|
|
1157
|
+
freeze = admission_status(repo)
|
|
1158
|
+
except CutoverError as error:
|
|
1159
|
+
if authority_pointer_path().exists():
|
|
1160
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "authority handoff is present") from error
|
|
1161
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "freeze authority is unresolved") from error
|
|
1162
|
+
receipt = load_receipt(migration_id)
|
|
1163
|
+
if freeze.get("state") == "ACTIVE" and freeze.get("migration_id") != migration_id:
|
|
1164
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "active freeze belongs to another migration")
|
|
1165
|
+
if receipt is not None and receipt.get("state") == "ABORTED_PRE_HANDOFF":
|
|
1166
|
+
return {"migration_id": migration_id, "state": "ABORTED_PRE_HANDOFF", "already_aborted": True}
|
|
1167
|
+
if freeze.get("state") != "ACTIVE" or freeze.get("migration_id") != migration_id:
|
|
1168
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "matching active freeze is required")
|
|
1169
|
+
candidates = discover_legacy_stores(repo)
|
|
1170
|
+
if len(candidates) != 1:
|
|
1171
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "legacy authority is unresolved")
|
|
1172
|
+
source = Path(candidates[0].resolved_path)
|
|
1173
|
+
if database_path(repo).resolve() != source.resolve() or authority_pointer_path().exists():
|
|
1174
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "authority handoff is present")
|
|
1175
|
+
target_state = classify_target(central_store_path())["state"]
|
|
1176
|
+
backups = installation_data_root() / "backups"
|
|
1177
|
+
if target_state != "ABSENT" or (backups.exists() and any(backups.glob(f"*{migration_id}*"))):
|
|
1178
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "target or backup is present")
|
|
1179
|
+
if receipt is not None and receipt.get("state") not in ABORTABLE_STATES:
|
|
1180
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "migration is beyond the abort boundary")
|
|
1181
|
+
quiescence = inspect_quiescence(source, pre_stop=True, services=services or LaunchAgentServiceControl())
|
|
1182
|
+
if not quiescence["eligible"]:
|
|
1183
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED", "active execution state is unsafe")
|
|
1184
|
+
if receipt is None:
|
|
1185
|
+
receipt = {
|
|
1186
|
+
"receipt_version": 1,
|
|
1187
|
+
"migration_id": migration_id,
|
|
1188
|
+
"schema": EXPECTED_SCHEMA,
|
|
1189
|
+
"legacy_path": str(source),
|
|
1190
|
+
"historical_freeze": freeze,
|
|
1191
|
+
"pre_stop_source": asdict(source_identity(candidates[0])),
|
|
1192
|
+
"rollback_mode": "NOT_REQUIRED_PRE_HANDOFF",
|
|
1193
|
+
}
|
|
1194
|
+
transition_receipt(receipt, "PRECHECK")
|
|
1195
|
+
transition_receipt(receipt, "ADMISSION_FROZEN", admission_freeze=freeze)
|
|
1196
|
+
receipt.setdefault("historical_freeze", freeze)
|
|
1197
|
+
return transition_receipt(
|
|
1198
|
+
receipt,
|
|
1199
|
+
"ABORTED_PRE_HANDOFF",
|
|
1200
|
+
abort={
|
|
1201
|
+
"reason": reason,
|
|
1202
|
+
"operator": operator,
|
|
1203
|
+
"timestamp": _now(),
|
|
1204
|
+
"authority": "LEGACY",
|
|
1205
|
+
"central_target_state": target_state,
|
|
1206
|
+
"authority_pointer": "ABSENT",
|
|
1207
|
+
"quiescence": quiescence,
|
|
1208
|
+
"tool_version": TOOL_VERSION,
|
|
1209
|
+
},
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def authority_pointer_path() -> Path:
|
|
1214
|
+
return installation_data_root() / "runtime" / "store-authority.json"
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def write_authority_pointer(*, migration_id: str, authority: Path, legacy: Path, state: str) -> dict[str, object]:
|
|
1218
|
+
if state not in STATES or not authority.is_file():
|
|
1219
|
+
raise CutoverError("AUTHORITY_SWITCH_FAILED")
|
|
1220
|
+
payload = {"version": POINTER_VERSION, "migration_id": migration_id, "authoritative_path": str(authority.resolve()), "legacy_path": str(legacy.resolve()), "schema": EXPECTED_SCHEMA, "state": state, "timestamp": _now(), "fingerprint_sha256": _fingerprint(authority)}
|
|
1221
|
+
_atomic_json(authority_pointer_path(), payload)
|
|
1222
|
+
return payload
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def copy_snapshot(source: Path, destination: Path) -> None:
|
|
1226
|
+
"""Create an fsynced SQLite backup snapshot, never a raw file copy."""
|
|
1227
|
+
temporary = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.tmp")
|
|
1228
|
+
destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
1229
|
+
try:
|
|
1230
|
+
with _readonly(source) as read_connection, sqlite3.connect(temporary) as write_connection:
|
|
1231
|
+
read_connection.backup(write_connection)
|
|
1232
|
+
with temporary.open("rb") as handle:
|
|
1233
|
+
os.fsync(handle.fileno())
|
|
1234
|
+
os.replace(temporary, destination)
|
|
1235
|
+
except (OSError, sqlite3.DatabaseError) as error:
|
|
1236
|
+
raise CutoverError("BACKUP_FAILED", str(error)) from error
|
|
1237
|
+
finally:
|
|
1238
|
+
if temporary.exists():
|
|
1239
|
+
temporary.unlink()
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def service_binding_proof(repo: Path, *, expected: Path, services: tuple[str, ...] = SERVICE_STOP_ORDER) -> dict[str, object]:
|
|
1243
|
+
"""All runtime surfaces share storage.database_path; mixed paths block."""
|
|
1244
|
+
try:
|
|
1245
|
+
resolved = database_path(repo).resolve()
|
|
1246
|
+
except Exception as error:
|
|
1247
|
+
raise CutoverError("CENTRAL_STORE_NOT_IN_USE") from error
|
|
1248
|
+
consistent = resolved == expected.resolve()
|
|
1249
|
+
return {"consistent": consistent, "authoritative_store": str(resolved), "services": {label: str(resolved) for label in services}}
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def quiescent_source_baseline(candidate: StoreCandidate) -> dict[str, object]:
|
|
1253
|
+
"""Capture the one authoritative source identity after strict quiescence."""
|
|
1254
|
+
source = Path(candidate.resolved_path)
|
|
1255
|
+
facts = inspect_source(candidate)
|
|
1256
|
+
scope = project_scope_inventory(source)
|
|
1257
|
+
if facts["blocking_codes"] or scope["blocking_codes"]:
|
|
1258
|
+
raise CutoverError("SOURCE_CHANGED_AFTER_PREFLIGHT")
|
|
1259
|
+
return {
|
|
1260
|
+
"state": "QUIESCENT_SOURCE_BASELINE",
|
|
1261
|
+
"captured_at": _now(),
|
|
1262
|
+
"source": asdict(source_identity(candidate)),
|
|
1263
|
+
"integrity": facts["integrity"],
|
|
1264
|
+
"critical_table_counts": _table_counts(source),
|
|
1265
|
+
"project_scope": scope,
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _require_quiescent_source_stable(candidate: StoreCandidate, baseline: dict[str, object]) -> StoreIdentity:
|
|
1270
|
+
identity = baseline.get("source")
|
|
1271
|
+
if not isinstance(identity, dict) or not _same_source_content(source_identity(candidate), identity):
|
|
1272
|
+
raise CutoverError("SOURCE_CHANGED_AFTER_PREFLIGHT")
|
|
1273
|
+
return source_identity(candidate)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def controlled_cutover(repo: Path, *, operator: str = "operator", services: LaunchAgentServiceControl | None = None) -> dict[str, object]:
|
|
1277
|
+
"""Perform one frozen cutover transaction through staged handoff gates."""
|
|
1278
|
+
candidate = discover_legacy_stores(repo)
|
|
1279
|
+
if len(candidate) != 1:
|
|
1280
|
+
raise CutoverError("QUIESCENCE_FAILED")
|
|
1281
|
+
source = Path(candidate[0].resolved_path)
|
|
1282
|
+
freeze = admission_status(repo)
|
|
1283
|
+
migration_id = freeze.get("migration_id")
|
|
1284
|
+
if freeze.get("state") != "ACTIVE" or not isinstance(migration_id, str) or not migration_id:
|
|
1285
|
+
raise CutoverError("ADMISSION_FREEZE_FAILED")
|
|
1286
|
+
existing = load_receipt(migration_id)
|
|
1287
|
+
if existing is not None and existing.get("state") not in {"PRECHECK", "ADMISSION_FROZEN", "QUIESCENT_SOURCE_BASELINE"}:
|
|
1288
|
+
if existing.get("state") in STATES:
|
|
1289
|
+
return existing
|
|
1290
|
+
raise CutoverError("AUTHORITY_SWITCH_FAILED", "migration receipt is invalid")
|
|
1291
|
+
receipt: dict[str, object] = existing or {
|
|
1292
|
+
"receipt_version": 1, "migration_id": migration_id, "schema": EXPECTED_SCHEMA,
|
|
1293
|
+
"operator": operator, "legacy_path": str(source), "rollback_mode": "PRE_WRITE_DIRECT",
|
|
1294
|
+
}
|
|
1295
|
+
if receipt.get("state") != "QUIESCENT_SOURCE_BASELINE":
|
|
1296
|
+
# A live watcher/dashboard lock is expected before maintenance, but a
|
|
1297
|
+
# failed pre-baseline attempt may already have durably unloaded every
|
|
1298
|
+
# owned LaunchAgent. That bounded state is safe to resume only when
|
|
1299
|
+
# the strict post-stop gate proves every service is still stopped.
|
|
1300
|
+
pre_stop = inspect_quiescence(source, pre_stop=True, services=services)
|
|
1301
|
+
already_quiesced = False
|
|
1302
|
+
if not pre_stop["eligible"]:
|
|
1303
|
+
post_stop = inspect_quiescence(source, services=services)
|
|
1304
|
+
services_stopped = services is not None and all(services.stopped(label) for label in SERVICE_STOP_ORDER)
|
|
1305
|
+
already_quiesced = existing is not None and post_stop["eligible"] and services_stopped
|
|
1306
|
+
if not already_quiesced:
|
|
1307
|
+
raise CutoverError("QUIESCENCE_FAILED")
|
|
1308
|
+
if services is not None and not already_quiesced:
|
|
1309
|
+
for label in SERVICE_STOP_ORDER:
|
|
1310
|
+
services.stop(label)
|
|
1311
|
+
if not services.stopped(label):
|
|
1312
|
+
raise CutoverError("SERVICE_STOP_FAILED", label)
|
|
1313
|
+
quiescence = inspect_quiescence(source, services=services)
|
|
1314
|
+
if not quiescence["eligible"]:
|
|
1315
|
+
raise CutoverError("QUIESCENCE_FAILED")
|
|
1316
|
+
baseline = quiescent_source_baseline(candidate[0])
|
|
1317
|
+
transition_receipt(
|
|
1318
|
+
receipt,
|
|
1319
|
+
"QUIESCENT_SOURCE_BASELINE",
|
|
1320
|
+
quiescence=quiescence,
|
|
1321
|
+
source=baseline["source"],
|
|
1322
|
+
quiescent_source_baseline=baseline,
|
|
1323
|
+
)
|
|
1324
|
+
else:
|
|
1325
|
+
quiescence = inspect_quiescence(source, services=services)
|
|
1326
|
+
if not quiescence["eligible"]:
|
|
1327
|
+
raise CutoverError("QUIESCENCE_FAILED")
|
|
1328
|
+
baseline = receipt.get("quiescent_source_baseline")
|
|
1329
|
+
if not isinstance(baseline, dict):
|
|
1330
|
+
raise CutoverError("AUTHORITY_SWITCH_FAILED", "quiescent source baseline is missing")
|
|
1331
|
+
_require_quiescent_source_stable(candidate[0], baseline)
|
|
1332
|
+
target = central_store_path()
|
|
1333
|
+
if classify_target(target)["state"] != "ABSENT":
|
|
1334
|
+
raise CutoverError("TARGET_CREATE_FAILED")
|
|
1335
|
+
_require_quiescent_source_stable(candidate[0], baseline)
|
|
1336
|
+
backup = installation_data_root() / "backups" / f"legacy-schema40-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{migration_id}.db"
|
|
1337
|
+
copy_snapshot(source, backup)
|
|
1338
|
+
if not validate_target_equivalence(source, backup)["equivalent"]:
|
|
1339
|
+
raise CutoverError("BACKUP_FAILED")
|
|
1340
|
+
transition_receipt(receipt, "BACKUP_VERIFIED", backup={"path": str(backup), "fingerprint_sha256": _fingerprint(backup)})
|
|
1341
|
+
copy_snapshot(source, target)
|
|
1342
|
+
transition_receipt(receipt, "CENTRAL_STORE_CREATED", target={"path": str(target)})
|
|
1343
|
+
equivalent = validate_target_equivalence(source, target)
|
|
1344
|
+
if not equivalent["equivalent"]:
|
|
1345
|
+
raise CutoverError("TARGET_EQUIVALENCE_FAILED")
|
|
1346
|
+
transition_receipt(receipt, "TARGET_VERIFIED", equivalence=equivalent)
|
|
1347
|
+
pointer = write_authority_pointer(migration_id=migration_id, authority=target, legacy=source, state="AUTHORITY_SWITCHED")
|
|
1348
|
+
transition_receipt(receipt, "AUTHORITY_SWITCHED", authority_pointer=pointer)
|
|
1349
|
+
if services is not None:
|
|
1350
|
+
for label in SERVICE_START_ORDER:
|
|
1351
|
+
services.start(label)
|
|
1352
|
+
transition_receipt(receipt, "SERVICES_RESTARTED", service_binding=service_binding_proof(repo, expected=target))
|
|
1353
|
+
return receipt
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def rollback(repo: Path, *, migration_id: str, operator: str = "operator") -> dict[str, object]:
|
|
1357
|
+
"""Restore legacy authority only before the first central production write."""
|
|
1358
|
+
receipt = load_receipt(migration_id)
|
|
1359
|
+
if receipt is None or receipt.get("state") != "LEGACY_ROLLBACK_COMPATIBLE":
|
|
1360
|
+
raise CutoverError("DIRECT_ROLLBACK_NOT_SAFE")
|
|
1361
|
+
freeze = admission_status(repo)
|
|
1362
|
+
if freeze.get("state") != "ACTIVE" or freeze.get("migration_id") != migration_id:
|
|
1363
|
+
raise CutoverError("ROLLBACK_FAILED", "freeze is not active")
|
|
1364
|
+
legacy = Path(str(receipt["legacy_path"]))
|
|
1365
|
+
source = receipt.get("source")
|
|
1366
|
+
if not legacy.is_file() or not isinstance(source, dict) or _fingerprint(legacy) != source.get("fingerprint_sha256"):
|
|
1367
|
+
raise CutoverError("ROLLBACK_FAILED", "legacy identity changed")
|
|
1368
|
+
target = central_store_path()
|
|
1369
|
+
if not inspect_quiescence(target)["eligible"]:
|
|
1370
|
+
raise CutoverError("ROLLBACK_FAILED", "central is not quiescent")
|
|
1371
|
+
pointer = write_authority_pointer(migration_id=migration_id, authority=legacy, legacy=legacy, state="LEGACY_ROLLBACK_COMPATIBLE")
|
|
1372
|
+
receipt["rollback"] = {"operator": operator, "timestamp": _now(), "authority_pointer": pointer}
|
|
1373
|
+
_atomic_json(receipt_path(migration_id), receipt)
|
|
1374
|
+
return receipt
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def _desired_state_matches(repo: Path) -> bool:
|
|
1378
|
+
"""Fail closed until a current Engineering Platform verifier is supplied.
|
|
1379
|
+
|
|
1380
|
+
The predecessor host bootstrap is intentionally not a current EP authority
|
|
1381
|
+
or readiness oracle. Callers may inject the installed EP verification
|
|
1382
|
+
boundary explicitly; this legacy migration module never invokes it.
|
|
1383
|
+
"""
|
|
1384
|
+
del repo
|
|
1385
|
+
return False
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def stage_a_readiness(
|
|
1389
|
+
repo: Path,
|
|
1390
|
+
*,
|
|
1391
|
+
migration_id: str,
|
|
1392
|
+
receipt: dict[str, object],
|
|
1393
|
+
services: LaunchAgentServiceControl | None = None,
|
|
1394
|
+
desired_state_check: object | None = None,
|
|
1395
|
+
) -> dict[str, object]:
|
|
1396
|
+
"""Prove the read-only Stage-A gates without changing runtime authority."""
|
|
1397
|
+
target = central_store_path()
|
|
1398
|
+
control = services or LaunchAgentServiceControl()
|
|
1399
|
+
try:
|
|
1400
|
+
running = {label: control.running(label) for label in SERVICE_START_ORDER}
|
|
1401
|
+
except Exception:
|
|
1402
|
+
raise CutoverError("POST_CUTOVER_READINESS_FAILED") from None
|
|
1403
|
+
try:
|
|
1404
|
+
pointer = json.loads(authority_pointer_path().read_text(encoding="utf-8"))
|
|
1405
|
+
except (OSError, json.JSONDecodeError):
|
|
1406
|
+
pointer = {}
|
|
1407
|
+
try:
|
|
1408
|
+
binding = service_binding_proof(repo, expected=target, services=SERVICE_START_ORDER)
|
|
1409
|
+
facts = inspect_source(StoreCandidate(str(target), str(target.resolve()), ("central_authority",)))
|
|
1410
|
+
freeze = admission_status(repo)
|
|
1411
|
+
resolved_authority = database_path(repo).resolve()
|
|
1412
|
+
except CutoverError:
|
|
1413
|
+
raise
|
|
1414
|
+
except (OSError, ValueError):
|
|
1415
|
+
raise CutoverError("POST_CUTOVER_READINESS_FAILED") from None
|
|
1416
|
+
|
|
1417
|
+
baseline = receipt.get("quiescent_source_baseline")
|
|
1418
|
+
baseline_source = baseline.get("source") if isinstance(baseline, dict) else None
|
|
1419
|
+
legacy = Path(str(receipt.get("legacy_path", "")))
|
|
1420
|
+
try:
|
|
1421
|
+
legacy_unchanged = (
|
|
1422
|
+
legacy.is_file()
|
|
1423
|
+
and isinstance(baseline_source, dict)
|
|
1424
|
+
and _same_source_content(
|
|
1425
|
+
source_identity(StoreCandidate(str(legacy), str(legacy.resolve()), ("legacy_authority",))),
|
|
1426
|
+
baseline_source,
|
|
1427
|
+
)
|
|
1428
|
+
)
|
|
1429
|
+
except OSError:
|
|
1430
|
+
legacy_unchanged = False
|
|
1431
|
+
equivalence = receipt.get("equivalence")
|
|
1432
|
+
verified_equivalence = isinstance(equivalence, dict) and equivalence.get("equivalent") is True
|
|
1433
|
+
pointer_matches = (
|
|
1434
|
+
pointer.get("migration_id") == migration_id
|
|
1435
|
+
and pointer.get("authoritative_path") == str(target.resolve())
|
|
1436
|
+
and pointer.get("schema") == EXPECTED_SCHEMA
|
|
1437
|
+
)
|
|
1438
|
+
checker = desired_state_check or _desired_state_matches
|
|
1439
|
+
try:
|
|
1440
|
+
desired_state_match = bool(checker(repo))
|
|
1441
|
+
except Exception:
|
|
1442
|
+
desired_state_match = False
|
|
1443
|
+
result = {
|
|
1444
|
+
"authority": "CENTRAL" if pointer_matches and resolved_authority == target.resolve() else "NOT_CENTRAL",
|
|
1445
|
+
"central_integrity": facts.get("integrity"),
|
|
1446
|
+
"central_schema": facts.get("schema_version"),
|
|
1447
|
+
"service_binding": binding,
|
|
1448
|
+
"services": running,
|
|
1449
|
+
"desired_state": "MATCH" if desired_state_match else "NOT_MATCH",
|
|
1450
|
+
"legacy_unchanged": legacy_unchanged,
|
|
1451
|
+
"freeze": freeze.get("state"),
|
|
1452
|
+
"pre_write_rollback_safe": receipt.get("rollback_mode") == "PRE_WRITE_DIRECT",
|
|
1453
|
+
"target_equivalence": verified_equivalence,
|
|
1454
|
+
"central_managed_production_writes": 0,
|
|
1455
|
+
}
|
|
1456
|
+
eligible = (
|
|
1457
|
+
result["authority"] == "CENTRAL"
|
|
1458
|
+
and not facts["blocking_codes"]
|
|
1459
|
+
and binding.get("consistent") is True
|
|
1460
|
+
and all(running.values())
|
|
1461
|
+
and desired_state_match
|
|
1462
|
+
and legacy_unchanged
|
|
1463
|
+
and freeze.get("state") == "ACTIVE"
|
|
1464
|
+
and freeze.get("migration_id") == migration_id
|
|
1465
|
+
and result["pre_write_rollback_safe"]
|
|
1466
|
+
and verified_equivalence
|
|
1467
|
+
)
|
|
1468
|
+
result["eligible"] = eligible
|
|
1469
|
+
return result
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def complete_stage_a(
|
|
1473
|
+
repo: Path,
|
|
1474
|
+
*,
|
|
1475
|
+
migration_id: str,
|
|
1476
|
+
services: LaunchAgentServiceControl | None = None,
|
|
1477
|
+
desired_state_check: object | None = None,
|
|
1478
|
+
) -> dict[str, object]:
|
|
1479
|
+
"""Record Stage-A only after the persisted restart and read-only gates pass."""
|
|
1480
|
+
receipt = load_receipt(migration_id)
|
|
1481
|
+
if receipt is None:
|
|
1482
|
+
raise CutoverError("POST_CUTOVER_READINESS_FAILED")
|
|
1483
|
+
if receipt.get("state") == "LEGACY_ROLLBACK_COMPATIBLE":
|
|
1484
|
+
return receipt
|
|
1485
|
+
if receipt.get("state") != "SERVICES_RESTARTED":
|
|
1486
|
+
raise CutoverError("POST_CUTOVER_READINESS_FAILED")
|
|
1487
|
+
readiness = stage_a_readiness(
|
|
1488
|
+
repo,
|
|
1489
|
+
migration_id=migration_id,
|
|
1490
|
+
receipt=receipt,
|
|
1491
|
+
services=services,
|
|
1492
|
+
desired_state_check=desired_state_check,
|
|
1493
|
+
)
|
|
1494
|
+
if not readiness["eligible"]:
|
|
1495
|
+
raise CutoverError("POST_CUTOVER_READINESS_FAILED")
|
|
1496
|
+
transition_receipt(receipt, "POST_CUTOVER_VERIFIED", readonly_qualification=readiness)
|
|
1497
|
+
return transition_receipt(receipt, "LEGACY_ROLLBACK_COMPATIBLE", rollback_mode="PRE_WRITE_DIRECT")
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def mark_central_post_write(repo: Path) -> None:
|
|
1501
|
+
"""One-way data-loss guard called only after an admitted central write."""
|
|
1502
|
+
pointer_path = authority_pointer_path()
|
|
1503
|
+
if not pointer_path.is_file():
|
|
1504
|
+
return
|
|
1505
|
+
try:
|
|
1506
|
+
pointer = json.loads(pointer_path.read_text(encoding="utf-8"))
|
|
1507
|
+
migration_id = str(pointer["migration_id"])
|
|
1508
|
+
except (OSError, KeyError, TypeError, json.JSONDecodeError) as error:
|
|
1509
|
+
raise CutoverError("CENTRAL_STORE_NOT_IN_USE") from error
|
|
1510
|
+
receipt = load_receipt(migration_id)
|
|
1511
|
+
if receipt is not None and receipt.get("state") == "LEGACY_ROLLBACK_COMPATIBLE":
|
|
1512
|
+
transition_receipt(receipt, "CENTRAL_STORE_ACTIVE_POST_WRITE", rollback_mode="REVERSE_MIGRATION_REQUIRED")
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def preflight(repo: Path, *, extra_runtime_roots: tuple[Path, ...] = ()) -> dict[str, object]:
|
|
1516
|
+
"""Compute the complete migration plan strictly read-only."""
|
|
1517
|
+
candidates = discover_legacy_stores(repo, extra_runtime_roots=extra_runtime_roots)
|
|
1518
|
+
try:
|
|
1519
|
+
freeze = admission_status(repo)
|
|
1520
|
+
except CutoverError:
|
|
1521
|
+
freeze = {"state": "UNRESOLVED"}
|
|
1522
|
+
receipt: dict[str, object] = {"receipt_version": 1, "mode": "DRY_RUN", "timestamp": datetime.now(timezone.utc).isoformat(), "tool_version": TOOL_VERSION, "target_data_root": str(installation_data_root()), "target_store": classify_target(central_store_path()), "source_candidates": [asdict(item) for item in candidates], "blocking_codes": [], "service_stop_plan": ["inbox_watcher", "separately_managed_execution_service", "local_consumer_api", "dashboard_relay", "dashboard"], "admission_freeze": freeze}
|
|
1523
|
+
if not candidates:
|
|
1524
|
+
receipt["blocking_codes"].append("LEGACY_STORE_NOT_FOUND")
|
|
1525
|
+
receipt["eligible"] = False
|
|
1526
|
+
return receipt
|
|
1527
|
+
if len(candidates) > 1:
|
|
1528
|
+
receipt["blocking_codes"].append("LEGACY_STORE_AMBIGUOUS")
|
|
1529
|
+
receipt["eligible"] = False
|
|
1530
|
+
return receipt
|
|
1531
|
+
candidate = candidates[0]
|
|
1532
|
+
source = inspect_source(candidate)
|
|
1533
|
+
identity = source_identity(candidate)
|
|
1534
|
+
quiescence = inspect_quiescence(Path(candidate.resolved_path))
|
|
1535
|
+
inventory = project_scope_inventory(Path(candidate.resolved_path))
|
|
1536
|
+
backup = backup_readiness(identity, installation_data_root())
|
|
1537
|
+
try:
|
|
1538
|
+
critical_counts = _table_counts(Path(candidate.resolved_path))
|
|
1539
|
+
except (OSError, sqlite3.DatabaseError):
|
|
1540
|
+
# The source inspection above has already recorded the fail-closed
|
|
1541
|
+
# integrity finding. Preflight must report that finding, never crash
|
|
1542
|
+
# while attempting optional inventory detail for the same bad source.
|
|
1543
|
+
critical_counts = {}
|
|
1544
|
+
receipt.update({"source": source, "quiescence": quiescence, "backup_readiness": backup, "snapshot_strategy": snapshot_plan(source), "project_scope": inventory, "critical_table_counts": critical_counts})
|
|
1545
|
+
codes = list(source["blocking_codes"]) + list(quiescence["blocking_codes"]) + list(inventory["blocking_codes"])
|
|
1546
|
+
if backup["blocking_code"]:
|
|
1547
|
+
codes.append(backup["blocking_code"])
|
|
1548
|
+
target_code = receipt["target_store"].get("blocking_code")
|
|
1549
|
+
if target_code:
|
|
1550
|
+
codes.append(str(target_code))
|
|
1551
|
+
receipt["blocking_codes"] = sorted(set(codes))
|
|
1552
|
+
receipt["eligible"] = not receipt["blocking_codes"]
|
|
1553
|
+
return receipt
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1557
|
+
parser = argparse.ArgumentParser(prog="engineering-central-store-migration")
|
|
1558
|
+
parser.add_argument("command", choices=("preflight", "dry-run", "freeze", "freeze-status", "abort", "thaw", "cutover", "stage-a", "rollback", "recover-contaminated-prewrite", "create-contamination-attestation", "forensic-delta", "forensic-attribution", "forensic-attribution-v2", "status"))
|
|
1559
|
+
parser.add_argument("--repo", type=Path, default=Path.cwd())
|
|
1560
|
+
parser.add_argument("--json", action="store_true")
|
|
1561
|
+
parser.add_argument("--migration-id")
|
|
1562
|
+
parser.add_argument("--baseline", type=Path, help="read-only baseline SQLite database for forensic-delta")
|
|
1563
|
+
parser.add_argument("--candidate", type=Path, help="read-only candidate SQLite database for forensic-delta")
|
|
1564
|
+
parser.add_argument("--output", type=Path, help="optional JSON output file for forensic-delta")
|
|
1565
|
+
parser.add_argument("--report", type=Path, help="immutable forensic-delta JSON input for forensic-attribution")
|
|
1566
|
+
parser.add_argument("--expected-report-digest", help="required expected report digest for forensic-attribution")
|
|
1567
|
+
parser.add_argument("--evidence-bundle", type=Path, help="optional immutable ancestry evidence JSON for forensic-attribution")
|
|
1568
|
+
parser.add_argument("--strict", action="store_true", help="fail forensic-delta when a table has no deterministic key")
|
|
1569
|
+
parser.add_argument("--reason")
|
|
1570
|
+
parser.add_argument("--operator", default="operator")
|
|
1571
|
+
parser.add_argument("--execute", action="store_true", help="required for a mutating production operation")
|
|
1572
|
+
args = parser.parse_args(argv)
|
|
1573
|
+
repo = args.repo.resolve()
|
|
1574
|
+
try:
|
|
1575
|
+
if args.command == "forensic-delta":
|
|
1576
|
+
if not args.baseline or not args.candidate or not args.migration_id:
|
|
1577
|
+
parser.error("forensic-delta requires --baseline, --candidate, and --migration-id")
|
|
1578
|
+
result = export_forensic_delta(args.baseline, args.candidate, migration_id=args.migration_id)
|
|
1579
|
+
if args.output:
|
|
1580
|
+
args.output.write_text(canonical_report_json(result) + "\n", encoding="utf-8")
|
|
1581
|
+
if args.json:
|
|
1582
|
+
print(canonical_report_json(result))
|
|
1583
|
+
else:
|
|
1584
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
1585
|
+
return 2 if args.strict and result["summary"]["tables_key_unresolved"] else 0
|
|
1586
|
+
if args.command == "forensic-attribution":
|
|
1587
|
+
if not args.report or not args.expected_report_digest:
|
|
1588
|
+
parser.error("forensic-attribution requires --report and --expected-report-digest")
|
|
1589
|
+
bindings = None
|
|
1590
|
+
if args.evidence_bundle:
|
|
1591
|
+
bindings = json.loads(args.evidence_bundle.read_text(encoding="utf-8"))
|
|
1592
|
+
if not isinstance(bindings, dict):
|
|
1593
|
+
raise ForensicAttributionError("FORENSIC_EVIDENCE_BUNDLE_INVALID")
|
|
1594
|
+
result = load_and_attribute(args.report, repository_root=repo, expected_report_digest=args.expected_report_digest, evidence_bindings=bindings)
|
|
1595
|
+
if args.output:
|
|
1596
|
+
args.output.write_text(canonical_attribution_json(result) + "\n", encoding="utf-8")
|
|
1597
|
+
if args.json:
|
|
1598
|
+
print(canonical_attribution_json(result))
|
|
1599
|
+
else:
|
|
1600
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
1601
|
+
return 0
|
|
1602
|
+
if args.command == "forensic-attribution-v2":
|
|
1603
|
+
if not args.report or not args.expected_report_digest:
|
|
1604
|
+
parser.error("forensic-attribution-v2 requires --report and --expected-report-digest")
|
|
1605
|
+
result = load_and_enrich_v2(args.report, repository_root=repo, expected_attribution_digest=args.expected_report_digest)
|
|
1606
|
+
if args.output:
|
|
1607
|
+
args.output.write_text(canonical_attribution_v2_json(result) + "\n", encoding="utf-8")
|
|
1608
|
+
if args.json:
|
|
1609
|
+
print(canonical_attribution_v2_json(result))
|
|
1610
|
+
else:
|
|
1611
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
1612
|
+
return 0
|
|
1613
|
+
if args.command in {"preflight", "dry-run"}:
|
|
1614
|
+
result = preflight(repo)
|
|
1615
|
+
elif args.command == "freeze-status":
|
|
1616
|
+
result = admission_status(repo)
|
|
1617
|
+
elif args.command == "status":
|
|
1618
|
+
result = {"admission_freeze": admission_status(repo), "authority_pointer": str(authority_pointer_path()), "authoritative_store": str(database_path(repo))}
|
|
1619
|
+
elif args.command == "recover-contaminated-prewrite" and not args.execute:
|
|
1620
|
+
if not args.migration_id:
|
|
1621
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED")
|
|
1622
|
+
result = contaminated_prewrite_status(repo, migration_id=args.migration_id)
|
|
1623
|
+
elif args.command == "create-contamination-attestation" and not args.execute:
|
|
1624
|
+
raise CutoverError("ADMISSION_FREEZE_FAILED", "--execute is required")
|
|
1625
|
+
elif not args.execute:
|
|
1626
|
+
raise CutoverError("ADMISSION_FREEZE_FAILED", "--execute is required")
|
|
1627
|
+
elif args.command == "freeze":
|
|
1628
|
+
if not args.reason:
|
|
1629
|
+
raise CutoverError("ADMISSION_FREEZE_FAILED")
|
|
1630
|
+
result = set_admission_freeze(repo, migration_id=args.migration_id, reason=args.reason, operator=args.operator)
|
|
1631
|
+
elif args.command == "abort":
|
|
1632
|
+
if not args.migration_id or not args.reason:
|
|
1633
|
+
raise CutoverError("ABORT_PRE_HANDOFF_FAILED")
|
|
1634
|
+
result = abort_pre_handoff(repo, migration_id=args.migration_id, reason=args.reason, operator=args.operator)
|
|
1635
|
+
elif args.command == "thaw":
|
|
1636
|
+
if not args.migration_id:
|
|
1637
|
+
raise CutoverError("THAW_FAILED")
|
|
1638
|
+
result = thaw_admission(repo, migration_id=args.migration_id, operator=args.operator)
|
|
1639
|
+
elif args.command == "rollback":
|
|
1640
|
+
if not args.migration_id:
|
|
1641
|
+
raise CutoverError("ROLLBACK_FAILED")
|
|
1642
|
+
result = rollback(repo, migration_id=args.migration_id, operator=args.operator)
|
|
1643
|
+
elif args.command == "recover-contaminated-prewrite":
|
|
1644
|
+
if not args.migration_id:
|
|
1645
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED")
|
|
1646
|
+
result = recover_contaminated_prewrite(repo, migration_id=args.migration_id, operator=args.operator, services=LaunchAgentServiceControl())
|
|
1647
|
+
elif args.command == "create-contamination-attestation":
|
|
1648
|
+
if not args.migration_id:
|
|
1649
|
+
raise CutoverError("CONTAMINATION_PROVENANCE_UNRESOLVED")
|
|
1650
|
+
result = create_contamination_attestation(repo, migration_id=args.migration_id, operator=args.operator)
|
|
1651
|
+
elif args.command == "stage-a":
|
|
1652
|
+
if not args.migration_id:
|
|
1653
|
+
raise CutoverError("POST_CUTOVER_READINESS_FAILED")
|
|
1654
|
+
result = complete_stage_a(repo, migration_id=args.migration_id, services=LaunchAgentServiceControl())
|
|
1655
|
+
else:
|
|
1656
|
+
result = controlled_cutover(repo, operator=args.operator, services=LaunchAgentServiceControl())
|
|
1657
|
+
except (CutoverError, ForensicDeltaError, ForensicAttributionError, ForensicAttributionV2Error) as error:
|
|
1658
|
+
result = {"ok": False, "code": error.code}
|
|
1659
|
+
if args.json:
|
|
1660
|
+
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
|
1661
|
+
else:
|
|
1662
|
+
print(error.code)
|
|
1663
|
+
return 2
|
|
1664
|
+
if args.json:
|
|
1665
|
+
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
|
1666
|
+
else:
|
|
1667
|
+
print(json.dumps(result, sort_keys=True))
|
|
1668
|
+
return 0 if args.command not in {"preflight", "dry-run"} or result["eligible"] else 2
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
if __name__ == "__main__":
|
|
1672
|
+
raise SystemExit(main())
|