dploydb 0.1.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.
dploydb/models.py ADDED
@@ -0,0 +1,927 @@
1
+ """Shared durable contracts for DployDB operations and failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+ from typing import Any, Literal, NewType, Self, TypedDict
10
+ from uuid import uuid4
11
+
12
+ from pydantic import (
13
+ BaseModel,
14
+ ConfigDict,
15
+ Field,
16
+ field_serializer,
17
+ field_validator,
18
+ model_validator,
19
+ )
20
+
21
+ OperationId = NewType("OperationId", str)
22
+
23
+
24
+ class DeploymentState(StrEnum):
25
+ """Durable states allowed by the DployDB deployment contract."""
26
+
27
+ CREATED = "created"
28
+ PREFLIGHT_PASSED = "preflight_passed"
29
+ SNAPSHOT_VERIFIED = "snapshot_verified"
30
+ REHEARSAL_PASSED = "rehearsal_passed"
31
+ CANDIDATE_HEALTHY = "candidate_healthy"
32
+ MAINTENANCE_ENABLED = "maintenance_enabled"
33
+ CURRENT_APP_STOPPED = "current_app_stopped"
34
+ FINAL_SNAPSHOT_VERIFIED = "final_snapshot_verified"
35
+ PRODUCTION_MIGRATED = "production_migrated"
36
+ NEW_APP_HEALTHY = "new_app_healthy"
37
+ TRAFFIC_ACTIVATED = "traffic_activated"
38
+ ACTIVE = "active"
39
+ ROLLBACK_STARTED = "rollback_started"
40
+ ROLLED_BACK = "rolled_back"
41
+ FAILED_SAFE = "failed_safe"
42
+ RECOVERY_REQUIRED = "recovery_required"
43
+ MANUAL_RESTORE_STARTED = "manual_restore_started"
44
+ MANUAL_RESTORE_COMPLETED = "manual_restore_completed"
45
+
46
+
47
+ class OperationStatus(StrEnum):
48
+ """Generic durable lifecycle for all state-tracked operations."""
49
+
50
+ IN_PROGRESS = "in_progress"
51
+ SUCCEEDED = "succeeded"
52
+ FAILED_SAFE = "failed_safe"
53
+ RECOVERY_REQUIRED = "recovery_required"
54
+
55
+
56
+ class BackupPurpose(StrEnum):
57
+ """Why an immutable verified backup was created."""
58
+
59
+ STANDALONE = "standalone"
60
+ PRE_RESTORE = "pre_restore"
61
+ REHEARSAL = "rehearsal"
62
+ FINAL = "final"
63
+
64
+
65
+ class LockOwnerState(StrEnum):
66
+ """Lifecycle recorded in the diagnostic deployment-lock owner file."""
67
+
68
+ ACTIVE = "active"
69
+ RELEASED = "released"
70
+
71
+
72
+ class DiagnosticOutcome(StrEnum):
73
+ """Stable outcome values emitted by host diagnostics."""
74
+
75
+ PASSED = "passed"
76
+ WARNING = "warning"
77
+ FAILED = "failed"
78
+ SKIPPED = "skipped"
79
+
80
+
81
+ class RuntimeStatus(StrEnum):
82
+ """Read-only summary of the deployment lock and durable operation state."""
83
+
84
+ IDLE = "idle"
85
+ ACTIVE = "active"
86
+ INTERRUPTED = "interrupted"
87
+ RECOVERY_REQUIRED = "recovery_required"
88
+
89
+
90
+ class DurableModel(BaseModel):
91
+ """Strict immutable base for versioned JSON state records."""
92
+
93
+ model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
94
+
95
+
96
+ class ProcessIdentity(DurableModel):
97
+ """Diagnostic identity of the process that created an operation."""
98
+
99
+ pid: int = Field(gt=0)
100
+ hostname: str = Field(min_length=1, max_length=255)
101
+
102
+
103
+ class LockOwnerMetadata(DurableModel):
104
+ """Versioned diagnostic metadata; the kernel lock remains authoritative."""
105
+
106
+ schema_version: Literal[1] = 1
107
+ owner_id: str = Field(pattern=r"^lock_[0-9a-f]{32}$")
108
+ operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
109
+ operation_type: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$")
110
+ process: ProcessIdentity
111
+ state: LockOwnerState
112
+ acquired_at: datetime
113
+ released_at: datetime | None = None
114
+
115
+ @field_validator("acquired_at", "released_at")
116
+ @classmethod
117
+ def normalize_lock_timestamp(cls, value: datetime | None) -> datetime | None:
118
+ if value is None:
119
+ return None
120
+ if value.tzinfo is None or value.utcoffset() is None:
121
+ raise ValueError("timestamp must be timezone-aware")
122
+ return value.astimezone(UTC)
123
+
124
+ @field_serializer("acquired_at", "released_at")
125
+ def serialize_lock_timestamp(self, value: datetime | None) -> str | None:
126
+ return None if value is None else serialize_utc_timestamp(value)
127
+
128
+ @model_validator(mode="after")
129
+ def validate_lock_lifecycle(self) -> Self:
130
+ if self.state is LockOwnerState.ACTIVE:
131
+ if self.released_at is not None:
132
+ raise ValueError("active lock owner must not have released_at")
133
+ elif self.released_at is None:
134
+ raise ValueError("released lock owner requires released_at")
135
+ if self.released_at is not None and self.released_at < self.acquired_at:
136
+ raise ValueError("released_at must not precede acquired_at")
137
+ return self
138
+
139
+
140
+ class SafetyFacts(DurableModel):
141
+ """Persisted safety facts needed to diagnose an interrupted operation."""
142
+
143
+ production_changed: bool = False
144
+ previous_application_running: bool | None = None
145
+ recovery_required: bool = False
146
+
147
+
148
+ class FailureRecord(DurableModel):
149
+ """Redacted durable details for an operation failure."""
150
+
151
+ error_code: str = Field(min_length=1, max_length=128)
152
+ what_failed: str = Field(min_length=1, max_length=4096)
153
+ log_path: str | None = Field(default=None, max_length=4096)
154
+ next_safe_action: str = Field(min_length=1, max_length=4096)
155
+
156
+
157
+ class OperationManifest(DurableModel):
158
+ """Atomic summary of one generic DployDB operation."""
159
+
160
+ schema_version: Literal[1] = 1
161
+ operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
162
+ operation_type: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$")
163
+ project: str = Field(min_length=1, max_length=64)
164
+ status: OperationStatus
165
+ stage: str = Field(pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
166
+ configuration_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
167
+ process: ProcessIdentity
168
+ safety: SafetyFacts
169
+ started_at: datetime
170
+ updated_at: datetime
171
+ completed_at: datetime | None = None
172
+ failure: FailureRecord | None = None
173
+ last_event_sequence: int = Field(ge=1)
174
+
175
+ @field_validator("started_at", "updated_at", "completed_at")
176
+ @classmethod
177
+ def normalize_timestamp(cls, value: datetime | None) -> datetime | None:
178
+ if value is None:
179
+ return None
180
+ if value.tzinfo is None or value.utcoffset() is None:
181
+ raise ValueError("timestamp must be timezone-aware")
182
+ return value.astimezone(UTC)
183
+
184
+ @field_serializer("started_at", "updated_at", "completed_at")
185
+ def serialize_timestamp(self, value: datetime | None) -> str | None:
186
+ return None if value is None else serialize_utc_timestamp(value)
187
+
188
+ @model_validator(mode="after")
189
+ def validate_lifecycle(self) -> Self:
190
+ if self.updated_at < self.started_at:
191
+ raise ValueError("updated_at must not precede started_at")
192
+
193
+ terminal = self.status is not OperationStatus.IN_PROGRESS
194
+ if terminal:
195
+ if self.completed_at is None:
196
+ raise ValueError("terminal operation requires completed_at")
197
+ if self.completed_at != self.updated_at:
198
+ raise ValueError("completed_at must equal updated_at for a terminal operation")
199
+ elif self.completed_at is not None:
200
+ raise ValueError("in-progress operation must not have completed_at")
201
+
202
+ failed = self.status in {
203
+ OperationStatus.FAILED_SAFE,
204
+ OperationStatus.RECOVERY_REQUIRED,
205
+ }
206
+ if failed != (self.failure is not None):
207
+ raise ValueError("failure details must be present exactly for failed operations")
208
+
209
+ recovery_required = self.status is OperationStatus.RECOVERY_REQUIRED
210
+ if self.safety.recovery_required != recovery_required:
211
+ raise ValueError("recovery_required safety fact must match operation status")
212
+ return self
213
+
214
+
215
+ class OperationEvent(DurableModel):
216
+ """One immutable append-only operation event."""
217
+
218
+ schema_version: Literal[1] = 1
219
+ sequence: int = Field(ge=1)
220
+ timestamp: datetime
221
+ operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
222
+ status: OperationStatus
223
+ stage: str = Field(pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
224
+ message: str = Field(min_length=1, max_length=4096)
225
+ evidence: dict[str, Any] = Field(default_factory=dict)
226
+
227
+ @field_validator("timestamp")
228
+ @classmethod
229
+ def normalize_event_timestamp(cls, value: datetime) -> datetime:
230
+ if value.tzinfo is None or value.utcoffset() is None:
231
+ raise ValueError("timestamp must be timezone-aware")
232
+ return value.astimezone(UTC)
233
+
234
+ @field_serializer("timestamp")
235
+ def serialize_event_timestamp(self, value: datetime) -> str:
236
+ return serialize_utc_timestamp(value)
237
+
238
+
239
+ class DiagnosticCheck(DurableModel):
240
+ """One stable, redacted doctor check result."""
241
+
242
+ check_id: str = Field(pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
243
+ outcome: DiagnosticOutcome
244
+ message: str = Field(min_length=1, max_length=4096)
245
+ evidence: dict[str, Any] = Field(default_factory=dict)
246
+
247
+
248
+ class SQLiteVerification(DurableModel):
249
+ """Bounded read-only evidence that a SQLite database passed required checks."""
250
+
251
+ schema_version: Literal[1] = 1
252
+ quick_check_passed: Literal[True] = True
253
+ foreign_key_check_passed: Literal[True] = True
254
+ integrity_check_passed: Literal[True] | None = None
255
+ checked_at: datetime
256
+ duration_seconds: float = Field(ge=0)
257
+
258
+ @field_validator("checked_at")
259
+ @classmethod
260
+ def normalize_checked_at(cls, value: datetime) -> datetime:
261
+ if value.tzinfo is None or value.utcoffset() is None:
262
+ raise ValueError("timestamp must be timezone-aware")
263
+ return value.astimezone(UTC)
264
+
265
+ @field_serializer("checked_at")
266
+ def serialize_checked_at(self, value: datetime) -> str:
267
+ return serialize_utc_timestamp(value)
268
+
269
+
270
+ class BackupMetadata(DurableModel):
271
+ """Metadata commit marker for one immutable verified local backup."""
272
+
273
+ schema_version: Literal[1] = 1
274
+ backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
275
+ project: str = Field(min_length=1, max_length=64)
276
+ purpose: BackupPurpose
277
+ source_database_path: Path
278
+ database_file_name: str = Field(pattern=r"^backup_[0-9a-f]{32}\.db$")
279
+ size_bytes: int = Field(gt=0)
280
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
281
+ sqlite: SQLiteVerification
282
+ operation_id: str | None = Field(default=None, pattern=r"^op_[0-9a-f]{32}$")
283
+ created_at: datetime
284
+ completed_at: datetime
285
+
286
+ @field_validator("source_database_path")
287
+ @classmethod
288
+ def validate_source_path(cls, value: Path) -> Path:
289
+ if not value.is_absolute():
290
+ raise ValueError("source database path must be absolute")
291
+ return value
292
+
293
+ @field_validator("created_at", "completed_at")
294
+ @classmethod
295
+ def normalize_backup_timestamp(cls, value: datetime) -> datetime:
296
+ if value.tzinfo is None or value.utcoffset() is None:
297
+ raise ValueError("timestamp must be timezone-aware")
298
+ return value.astimezone(UTC)
299
+
300
+ @field_serializer("created_at", "completed_at")
301
+ def serialize_backup_timestamp(self, value: datetime) -> str:
302
+ return serialize_utc_timestamp(value)
303
+
304
+ @model_validator(mode="after")
305
+ def validate_backup_identity(self) -> Self:
306
+ if self.database_file_name != f"{self.backup_id}.db":
307
+ raise ValueError("database filename must match backup ID")
308
+ if self.completed_at < self.created_at:
309
+ raise ValueError("completed_at must not precede created_at")
310
+ return self
311
+
312
+
313
+ class BackupArtifact(DurableModel):
314
+ """Resolved paths and metadata for one committed local backup."""
315
+
316
+ metadata: BackupMetadata
317
+ database_path: Path
318
+ metadata_path: Path
319
+
320
+ @field_validator("database_path", "metadata_path")
321
+ @classmethod
322
+ def validate_artifact_path(cls, value: Path) -> Path:
323
+ if not value.is_absolute():
324
+ raise ValueError("backup artifact paths must be absolute")
325
+ return value
326
+
327
+ @model_validator(mode="after")
328
+ def validate_artifact_identity(self) -> Self:
329
+ if self.database_path.name != self.metadata.database_file_name:
330
+ raise ValueError("database artifact path does not match metadata")
331
+ if self.metadata_path.name != f"{self.metadata.backup_id}.json":
332
+ raise ValueError("metadata artifact path does not match backup ID")
333
+ if self.database_path.parent != self.metadata_path.parent:
334
+ raise ValueError("backup database and metadata must share a directory")
335
+ return self
336
+
337
+
338
+ class RemoteBackupMetadata(DurableModel):
339
+ """Metadata-last commit marker for one immutable off-server backup."""
340
+
341
+ schema_version: Literal[1] = 1
342
+ backup: BackupMetadata
343
+ release_id: str | None = Field(default=None, pattern=r"^release_[0-9a-f]{32}$")
344
+ database_object_key: str = Field(min_length=1, max_length=1024)
345
+ uploaded_at: datetime
346
+
347
+ @field_validator("database_object_key")
348
+ @classmethod
349
+ def validate_database_object_key(cls, value: str) -> str:
350
+ parts = value.split("/")
351
+ if (
352
+ value.startswith("/")
353
+ or "\x00" in value
354
+ or any(part in {"", ".", ".."} for part in parts)
355
+ ):
356
+ raise ValueError("remote database object key must be normalized and relative")
357
+ if len(value.encode("utf-8")) > 1024:
358
+ raise ValueError("remote database object key exceeds the S3 key limit")
359
+ return value
360
+
361
+ @field_validator("uploaded_at")
362
+ @classmethod
363
+ def normalize_uploaded_at(cls, value: datetime) -> datetime:
364
+ if value.tzinfo is None or value.utcoffset() is None:
365
+ raise ValueError("timestamp must be timezone-aware")
366
+ normalized = value.astimezone(UTC)
367
+ return normalized.replace(microsecond=(normalized.microsecond // 1000) * 1000)
368
+
369
+ @field_serializer("uploaded_at")
370
+ def serialize_uploaded_at(self, value: datetime) -> str:
371
+ return serialize_utc_timestamp(value)
372
+
373
+ @model_validator(mode="after")
374
+ def validate_remote_identity(self) -> Self:
375
+ if self.database_object_key.split("/")[-1] != self.backup.database_file_name:
376
+ raise ValueError("remote database object key does not match backup identity")
377
+ return self
378
+
379
+
380
+ class RemoteBackupArtifact(DurableModel):
381
+ """Resolved non-secret identity of one committed remote backup."""
382
+
383
+ metadata: RemoteBackupMetadata
384
+ bucket: str = Field(min_length=1, max_length=255)
385
+ metadata_object_key: str = Field(min_length=1, max_length=1024)
386
+
387
+ @field_validator("metadata_object_key")
388
+ @classmethod
389
+ def validate_metadata_object_key(cls, value: str) -> str:
390
+ parts = value.split("/")
391
+ if (
392
+ value.startswith("/")
393
+ or "\x00" in value
394
+ or any(part in {"", ".", ".."} for part in parts)
395
+ ):
396
+ raise ValueError("remote metadata object key must be normalized and relative")
397
+ if len(value.encode("utf-8")) > 1024:
398
+ raise ValueError("remote metadata object key exceeds the S3 key limit")
399
+ return value
400
+
401
+ @model_validator(mode="after")
402
+ def validate_remote_artifact_identity(self) -> Self:
403
+ expected = f"{self.metadata.backup.backup_id}.json"
404
+ if self.metadata_object_key.split("/")[-1] != expected:
405
+ raise ValueError("remote metadata object key does not match backup identity")
406
+ return self
407
+
408
+
409
+ class RestoreResult(DurableModel):
410
+ """Durable result returned by the internal stopped-application restore engine."""
411
+
412
+ selected_backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
413
+ pre_restore_backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
414
+ database_path: Path
415
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
416
+ restored_at: datetime
417
+
418
+ @field_validator("database_path")
419
+ @classmethod
420
+ def validate_restore_path(cls, value: Path) -> Path:
421
+ if not value.is_absolute():
422
+ raise ValueError("restored database path must be absolute")
423
+ return value
424
+
425
+ @field_validator("restored_at")
426
+ @classmethod
427
+ def normalize_restored_at(cls, value: datetime) -> datetime:
428
+ if value.tzinfo is None or value.utcoffset() is None:
429
+ raise ValueError("timestamp must be timezone-aware")
430
+ return value.astimezone(UTC)
431
+
432
+ @field_serializer("restored_at")
433
+ def serialize_restored_at(self, value: datetime) -> str:
434
+ return serialize_utc_timestamp(value)
435
+
436
+
437
+ class ProductionApplicationHandle(DurableModel):
438
+ """Exact Docker application identity preserved across cutover and rollback."""
439
+
440
+ source: Literal["bootstrap", "release"]
441
+ container_id: str = Field(pattern=r"^[0-9a-f]{12,64}$")
442
+ container_name: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$")
443
+ compose_project: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
444
+ compose_service: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
445
+ version: str | None = Field(
446
+ default=None,
447
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
448
+ )
449
+ release_id: str | None = Field(default=None, pattern=r"^release_[0-9a-f]{32}$")
450
+ operation_id: str | None = Field(default=None, pattern=r"^op_[0-9a-f]{32}$")
451
+ database_directory: Path
452
+ database_target: str = Field(pattern=r"^/[^\x00:]+$")
453
+ host_port: int = Field(ge=1, le=65535)
454
+ container_port: int = Field(ge=1, le=65535)
455
+ health_url: str = Field(min_length=1, max_length=4096)
456
+
457
+ @field_validator("database_directory")
458
+ @classmethod
459
+ def validate_database_directory(cls, value: Path) -> Path:
460
+ if not value.is_absolute() or value == Path("/"):
461
+ raise ValueError("database directory must be an absolute non-root path")
462
+ return value
463
+
464
+ @model_validator(mode="after")
465
+ def validate_source_identity(self) -> Self:
466
+ if self.source == "release":
467
+ if self.version is None or self.release_id is None or self.operation_id is None:
468
+ raise ValueError(
469
+ "release application handle requires version, release_id, and operation_id"
470
+ )
471
+ elif self.release_id is not None or self.operation_id is not None:
472
+ raise ValueError(
473
+ "bootstrap application handle must not claim a release_id or operation_id"
474
+ )
475
+ return self
476
+
477
+
478
+ class ReleaseHookEvidence(DurableModel):
479
+ """Bounded hook summary kept with the release; full capture stays in events."""
480
+
481
+ action: Literal[
482
+ "enable_maintenance",
483
+ "disable_maintenance",
484
+ "activate_new",
485
+ "activate_old",
486
+ ]
487
+ passed: bool
488
+ outcome: Literal[
489
+ "succeeded",
490
+ "nonzero_exit",
491
+ "start_failed",
492
+ "timed_out",
493
+ "cancelled",
494
+ "cleanup_failed",
495
+ ]
496
+ exit_code: int | None
497
+ output_complete: bool
498
+ duration_seconds: float = Field(ge=0)
499
+ termination_reason: Literal["timeout", "cancellation", "interruption"] | None = None
500
+ forced_kill: bool = False
501
+ cleanup_error: str | None = Field(default=None, max_length=4096)
502
+
503
+
504
+ class ReleaseHealthEvidence(DurableModel):
505
+ """Passing new or previous application health summary for one release."""
506
+
507
+ role: Literal["new", "previous"]
508
+ version: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
509
+ url: str = Field(min_length=1, max_length=4096)
510
+ readiness_attempts: int = Field(gt=0)
511
+ smoke_outcome: Literal["succeeded"] | None = None
512
+
513
+
514
+ class ReleaseManifest(DurableModel):
515
+ """Atomic durable summary of one deployment release and its safety facts."""
516
+
517
+ schema_version: Literal[1] = 1
518
+ recovery_protocol_version: Literal[2] | None = None
519
+ release_id: str = Field(pattern=r"^release_[0-9a-f]{32}$")
520
+ operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
521
+ project: str = Field(min_length=1, max_length=64)
522
+ requested_version: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
523
+ status: DeploymentState
524
+ configuration_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
525
+ operation_log_path: Path
526
+ previous_release_id: str | None = Field(default=None, pattern=r"^release_[0-9a-f]{32}$")
527
+ previous_application: ProductionApplicationHandle | None = None
528
+ new_application: ProductionApplicationHandle | None = None
529
+ rehearsal_backup_id: str | None = Field(default=None, pattern=r"^backup_[0-9a-f]{32}$")
530
+ rehearsal_backup_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
531
+ final_backup_id: str | None = Field(default=None, pattern=r"^backup_[0-9a-f]{32}$")
532
+ final_backup_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
533
+ production_health_passed: bool = False
534
+ production_changed: bool = False
535
+ production_migration_started: bool = False
536
+ traffic_activation_attempted: bool = False
537
+ traffic_activated: bool = False
538
+ traffic_hooks: tuple[ReleaseHookEvidence, ...] = ()
539
+ health_checks: tuple[ReleaseHealthEvidence, ...] = ()
540
+ started_at: datetime
541
+ updated_at: datetime
542
+ completed_at: datetime | None = None
543
+ failure: FailureRecord | None = None
544
+ recovery_operation_id: str | None = Field(default=None, pattern=r"^op_[0-9a-f]{32}$")
545
+ recovered_at: datetime | None = None
546
+ recovery_failure: FailureRecord | None = None
547
+
548
+ @field_validator("operation_log_path")
549
+ @classmethod
550
+ def validate_operation_log_path(cls, value: Path) -> Path:
551
+ if not value.is_absolute():
552
+ raise ValueError("operation log path must be absolute")
553
+ return value
554
+
555
+ @field_validator("started_at", "updated_at", "completed_at", "recovered_at")
556
+ @classmethod
557
+ def normalize_release_timestamp(cls, value: datetime | None) -> datetime | None:
558
+ if value is None:
559
+ return None
560
+ if value.tzinfo is None or value.utcoffset() is None:
561
+ raise ValueError("timestamp must be timezone-aware")
562
+ return value.astimezone(UTC)
563
+
564
+ @field_serializer("started_at", "updated_at", "completed_at", "recovered_at")
565
+ def serialize_release_timestamp(self, value: datetime | None) -> str | None:
566
+ return None if value is None else serialize_utc_timestamp(value)
567
+
568
+ @model_validator(mode="after")
569
+ def validate_release_lifecycle(self) -> Self:
570
+ if self.updated_at < self.started_at:
571
+ raise ValueError("updated_at must not precede started_at")
572
+ if (self.rehearsal_backup_id is None) != (self.rehearsal_backup_sha256 is None):
573
+ raise ValueError("rehearsal backup ID and checksum must be recorded together")
574
+ if (self.final_backup_id is None) != (self.final_backup_sha256 is None):
575
+ raise ValueError("final backup ID and checksum must be recorded together")
576
+
577
+ terminal = self.status in {
578
+ DeploymentState.ACTIVE,
579
+ DeploymentState.ROLLED_BACK,
580
+ DeploymentState.FAILED_SAFE,
581
+ DeploymentState.RECOVERY_REQUIRED,
582
+ }
583
+ if terminal != (self.completed_at is not None):
584
+ raise ValueError("terminal release state and completed_at must agree")
585
+ if self.completed_at is not None and self.completed_at != self.updated_at:
586
+ raise ValueError("completed_at must equal updated_at for a terminal release")
587
+
588
+ failed = self.status in {
589
+ DeploymentState.ROLLED_BACK,
590
+ DeploymentState.FAILED_SAFE,
591
+ DeploymentState.RECOVERY_REQUIRED,
592
+ }
593
+ if failed != (self.failure is not None):
594
+ raise ValueError("failure details must be present exactly for non-active terminals")
595
+
596
+ recovery_fields = (
597
+ self.recovery_operation_id,
598
+ self.recovered_at,
599
+ self.recovery_failure,
600
+ )
601
+ if any(value is not None for value in recovery_fields):
602
+ if not all(value is not None for value in recovery_fields):
603
+ raise ValueError("recovery resolution fields must be recorded together")
604
+ if self.status not in {DeploymentState.ACTIVE, DeploymentState.ROLLED_BACK}:
605
+ raise ValueError("only active or rolled-back releases can record resolution")
606
+
607
+ if self.status in {
608
+ DeploymentState.MANUAL_RESTORE_STARTED,
609
+ DeploymentState.MANUAL_RESTORE_COMPLETED,
610
+ }:
611
+ raise ValueError("manual restore states do not belong to a deployment release")
612
+ if self.previous_release_id is not None and self.previous_application is not None:
613
+ if self.previous_application.release_id != self.previous_release_id:
614
+ raise ValueError("previous release and application identities contradict")
615
+ if self.new_application is not None and (
616
+ self.new_application.release_id != self.release_id
617
+ or self.new_application.version != self.requested_version
618
+ or self.new_application.operation_id != self.operation_id
619
+ ):
620
+ raise ValueError("new application identity contradicts the release manifest")
621
+ rehearsal_required = self.status in {
622
+ DeploymentState.SNAPSHOT_VERIFIED,
623
+ DeploymentState.REHEARSAL_PASSED,
624
+ DeploymentState.CANDIDATE_HEALTHY,
625
+ DeploymentState.MAINTENANCE_ENABLED,
626
+ DeploymentState.CURRENT_APP_STOPPED,
627
+ DeploymentState.FINAL_SNAPSHOT_VERIFIED,
628
+ DeploymentState.PRODUCTION_MIGRATED,
629
+ DeploymentState.NEW_APP_HEALTHY,
630
+ DeploymentState.TRAFFIC_ACTIVATED,
631
+ DeploymentState.ACTIVE,
632
+ DeploymentState.ROLLBACK_STARTED,
633
+ DeploymentState.ROLLED_BACK,
634
+ }
635
+ if rehearsal_required and self.rehearsal_backup_id is None:
636
+ raise ValueError("release state requires verified rehearsal backup evidence")
637
+ previous_required = self.status in {
638
+ DeploymentState.CURRENT_APP_STOPPED,
639
+ DeploymentState.FINAL_SNAPSHOT_VERIFIED,
640
+ DeploymentState.PRODUCTION_MIGRATED,
641
+ DeploymentState.NEW_APP_HEALTHY,
642
+ DeploymentState.TRAFFIC_ACTIVATED,
643
+ DeploymentState.ACTIVE,
644
+ DeploymentState.ROLLBACK_STARTED,
645
+ DeploymentState.ROLLED_BACK,
646
+ }
647
+ if previous_required and self.previous_application is None:
648
+ raise ValueError("release state requires the exact previous application identity")
649
+ if self.traffic_activated and self.status not in {
650
+ DeploymentState.TRAFFIC_ACTIVATED,
651
+ DeploymentState.ACTIVE,
652
+ DeploymentState.RECOVERY_REQUIRED,
653
+ }:
654
+ raise ValueError("traffic activation fact contradicts the release state")
655
+ if self.traffic_activated and not self.production_changed:
656
+ raise ValueError("activated traffic requires a changed production release")
657
+ if self.production_migration_started and self.final_backup_id is None:
658
+ raise ValueError("production migration intent requires a verified final backup")
659
+ if self.traffic_activation_attempted and (
660
+ not self.production_migration_started
661
+ or not self.production_changed
662
+ or self.new_application is None
663
+ or not self.production_health_passed
664
+ ):
665
+ raise ValueError("traffic activation intent requires a checked production application")
666
+ if self.traffic_activated and not self.traffic_activation_attempted:
667
+ raise ValueError("activated traffic requires a durable activation attempt")
668
+ if self.status is DeploymentState.FAILED_SAFE and (
669
+ self.production_changed or self.traffic_activated
670
+ ):
671
+ raise ValueError("failed_safe release must leave production unchanged")
672
+ if self.status is DeploymentState.ROLLED_BACK and self.traffic_activated:
673
+ raise ValueError("rolled_back release cannot have activated new traffic")
674
+
675
+ if self.status in {
676
+ DeploymentState.PRODUCTION_MIGRATED,
677
+ DeploymentState.NEW_APP_HEALTHY,
678
+ DeploymentState.TRAFFIC_ACTIVATED,
679
+ DeploymentState.ACTIVE,
680
+ } and (self.final_backup_id is None or not self.production_changed):
681
+ raise ValueError("production mutation requires the verified final backup")
682
+ if (
683
+ self.status
684
+ in {
685
+ DeploymentState.PRODUCTION_MIGRATED,
686
+ DeploymentState.NEW_APP_HEALTHY,
687
+ DeploymentState.TRAFFIC_ACTIVATED,
688
+ DeploymentState.ACTIVE,
689
+ }
690
+ and not self.production_migration_started
691
+ ):
692
+ raise ValueError("production mutation state requires durable migration intent")
693
+ if self.status in {
694
+ DeploymentState.NEW_APP_HEALTHY,
695
+ DeploymentState.TRAFFIC_ACTIVATED,
696
+ DeploymentState.ACTIVE,
697
+ } and (self.new_application is None or not self.production_health_passed):
698
+ raise ValueError("healthy new-application evidence is required")
699
+ if self.status in {DeploymentState.TRAFFIC_ACTIVATED, DeploymentState.ACTIVE} and not (
700
+ self.traffic_activated
701
+ ):
702
+ raise ValueError("traffic-activated state requires the stored traffic fact")
703
+ if self.status is DeploymentState.ACTIVE and self.previous_application is None:
704
+ raise ValueError("active release must preserve the previous application identity")
705
+ return self
706
+
707
+
708
+ class ReleasePointers(DurableModel):
709
+ """Atomic selection of the active and immediately previous release manifests."""
710
+
711
+ schema_version: Literal[1] = 1
712
+ active_release_id: str = Field(pattern=r"^release_[0-9a-f]{32}$")
713
+ previous_release_id: str | None = Field(default=None, pattern=r"^release_[0-9a-f]{32}$")
714
+ updated_at: datetime
715
+
716
+ @field_validator("updated_at")
717
+ @classmethod
718
+ def normalize_pointer_timestamp(cls, value: datetime) -> datetime:
719
+ if value.tzinfo is None or value.utcoffset() is None:
720
+ raise ValueError("timestamp must be timezone-aware")
721
+ return value.astimezone(UTC)
722
+
723
+ @field_serializer("updated_at")
724
+ def serialize_pointer_timestamp(self, value: datetime) -> str:
725
+ return serialize_utc_timestamp(value)
726
+
727
+ @model_validator(mode="after")
728
+ def validate_pointer_identity(self) -> Self:
729
+ if self.previous_release_id == self.active_release_id:
730
+ raise ValueError("active and previous release IDs must differ")
731
+ return self
732
+
733
+
734
+ class CapturedCommandOutput(DurableModel):
735
+ """One complete bounded and redacted subprocess stream."""
736
+
737
+ text: str
738
+ total_bytes: int = Field(ge=0)
739
+ retained_bytes: int = Field(ge=0)
740
+ truncated: bool
741
+
742
+ @model_validator(mode="after")
743
+ def validate_capture(self) -> Self:
744
+ if self.retained_bytes > self.total_bytes:
745
+ raise ValueError("retained output bytes must not exceed total bytes")
746
+ if self.truncated != (self.retained_bytes < self.total_bytes):
747
+ raise ValueError("output truncation metadata is contradictory")
748
+ return self
749
+
750
+
751
+ class MigrationCommandEvidence(DurableModel):
752
+ """Redacted durable evidence for the developer-supplied migration command."""
753
+
754
+ command: tuple[str, ...]
755
+ working_directory: str
756
+ environment_keys: tuple[str, ...]
757
+ outcome: Literal[
758
+ "succeeded",
759
+ "nonzero_exit",
760
+ "start_failed",
761
+ "timed_out",
762
+ "cancelled",
763
+ "cleanup_failed",
764
+ ]
765
+ exit_code: int | None
766
+ stdout: CapturedCommandOutput
767
+ stderr: CapturedCommandOutput
768
+ duration_seconds: float = Field(ge=0)
769
+ termination_reason: Literal["timeout", "cancellation", "interruption"] | None = None
770
+ termination_attempted: bool = False
771
+ forced_kill: bool = False
772
+ start_error: str | None = None
773
+ cleanup_error: str | None = None
774
+
775
+
776
+ class MigrationRehearsalResult(DurableModel):
777
+ """Verified evidence produced by a completed migration rehearsal."""
778
+
779
+ operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
780
+ backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
781
+ backup_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
782
+ database_size_bytes: int = Field(gt=0)
783
+ database_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
784
+ command: MigrationCommandEvidence
785
+ sqlite: SQLiteVerification
786
+ completed_at: datetime
787
+
788
+ @field_validator("completed_at")
789
+ @classmethod
790
+ def normalize_completed_at(cls, value: datetime) -> datetime:
791
+ if value.tzinfo is None or value.utcoffset() is None:
792
+ raise ValueError("timestamp must be timezone-aware")
793
+ return value.astimezone(UTC)
794
+
795
+ @field_serializer("completed_at")
796
+ def serialize_completed_at(self, value: datetime) -> str:
797
+ return serialize_utc_timestamp(value)
798
+
799
+
800
+ class ProductionMigrationResult(DurableModel):
801
+ """Checked evidence produced after running the rehearsed command on production."""
802
+
803
+ operation_id: str = Field(pattern=r"^op_[0-9a-f]{32}$")
804
+ final_backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
805
+ final_backup_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
806
+ command: MigrationCommandEvidence
807
+ sqlite: SQLiteVerification
808
+ completed_at: datetime
809
+
810
+ @field_validator("completed_at")
811
+ @classmethod
812
+ def normalize_production_migration_time(cls, value: datetime) -> datetime:
813
+ if value.tzinfo is None or value.utcoffset() is None:
814
+ raise ValueError("timestamp must be timezone-aware")
815
+ return value.astimezone(UTC)
816
+
817
+ @field_serializer("completed_at")
818
+ def serialize_production_migration_time(self, value: datetime) -> str:
819
+ return serialize_utc_timestamp(value)
820
+
821
+
822
+ class VerifiedDatabaseRestoreResult(DurableModel):
823
+ """Evidence that one verified backup now exactly occupies production."""
824
+
825
+ backup_id: str = Field(pattern=r"^backup_[0-9a-f]{32}$")
826
+ database_path: Path
827
+ size_bytes: int = Field(gt=0)
828
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
829
+ sqlite: SQLiteVerification
830
+ restored_at: datetime
831
+
832
+ @field_validator("database_path")
833
+ @classmethod
834
+ def validate_verified_restore_path(cls, value: Path) -> Path:
835
+ if not value.is_absolute():
836
+ raise ValueError("restored database path must be absolute")
837
+ return value
838
+
839
+ @field_validator("restored_at")
840
+ @classmethod
841
+ def normalize_verified_restore_time(cls, value: datetime) -> datetime:
842
+ if value.tzinfo is None or value.utcoffset() is None:
843
+ raise ValueError("timestamp must be timezone-aware")
844
+ return value.astimezone(UTC)
845
+
846
+ @field_serializer("restored_at")
847
+ def serialize_verified_restore_time(self, value: datetime) -> str:
848
+ return serialize_utc_timestamp(value)
849
+
850
+
851
+ class FailureData(TypedDict):
852
+ """Stable JSON-compatible failure shape used by human and CI output."""
853
+
854
+ ok: Literal[False]
855
+ error_code: str
856
+ exit_code: int
857
+ what_failed: str
858
+ production_changed: bool
859
+ previous_application_running: bool | None
860
+ recovery_required: bool
861
+ log_path: str | None
862
+ next_safe_action: str
863
+
864
+
865
+ @dataclass(frozen=True, slots=True)
866
+ class FailurePayload:
867
+ """Safety facts that every expected DployDB failure must report."""
868
+
869
+ error_code: str
870
+ exit_code: int
871
+ what_failed: str
872
+ production_changed: bool
873
+ previous_application_running: bool | None
874
+ recovery_required: bool
875
+ log_path: str | None
876
+ next_safe_action: str
877
+
878
+ def __post_init__(self) -> None:
879
+ if not self.error_code.strip():
880
+ raise ValueError("error_code must not be empty")
881
+ if self.exit_code <= 0:
882
+ raise ValueError("failure exit_code must be positive")
883
+ if not self.what_failed.strip():
884
+ raise ValueError("what_failed must not be empty")
885
+ if not self.next_safe_action.strip():
886
+ raise ValueError("next_safe_action must not be empty")
887
+
888
+ def as_dict(self) -> FailureData:
889
+ """Return the stable machine-readable representation."""
890
+ return {
891
+ "ok": False,
892
+ "error_code": self.error_code,
893
+ "exit_code": self.exit_code,
894
+ "what_failed": self.what_failed,
895
+ "production_changed": self.production_changed,
896
+ "previous_application_running": self.previous_application_running,
897
+ "recovery_required": self.recovery_required,
898
+ "log_path": self.log_path,
899
+ "next_safe_action": self.next_safe_action,
900
+ }
901
+
902
+
903
+ def new_operation_id() -> OperationId:
904
+ """Create an opaque identifier suitable for durable operation records."""
905
+ return OperationId(f"op_{uuid4().hex}")
906
+
907
+
908
+ def new_backup_id() -> str:
909
+ """Create an opaque identifier for one immutable backup artifact."""
910
+ return f"backup_{uuid4().hex}"
911
+
912
+
913
+ def new_release_id() -> str:
914
+ """Create an opaque identifier for one durable deployment release."""
915
+ return f"release_{uuid4().hex}"
916
+
917
+
918
+ def utc_now() -> datetime:
919
+ """Return an aware current UTC timestamp."""
920
+ return datetime.now(UTC)
921
+
922
+
923
+ def serialize_utc_timestamp(value: datetime) -> str:
924
+ """Serialize an aware timestamp as stable RFC 3339 UTC with milliseconds."""
925
+ if value.tzinfo is None or value.utcoffset() is None:
926
+ raise ValueError("timestamp must be timezone-aware")
927
+ return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")