feat(observatory): seal blocked run preparations

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 23:14:05 +03:00
parent 8d5aeb0533
commit 2a5763d3fb
12 changed files with 1935 additions and 25 deletions
+34
View File
@@ -3,6 +3,24 @@
from k1link.observatory.canonical_result import (
is_admitted_observatory_recorded_result,
)
from k1link.observatory.run_preparations import (
MAX_RUN_PREPARATION_RECORDS,
MAX_RUN_PREPARATION_STORAGE_BYTES,
OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA,
OBSERVATORY_RUN_PREPARATION_SCHEMA,
RUN_PREPARATION_DATABASE_NAME,
RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS,
ObservatoryRunPreparation,
ObservatoryRunPreparationCapacityError,
ObservatoryRunPreparationConflictError,
ObservatoryRunPreparationError,
ObservatoryRunPreparationIntegrityError,
ObservatoryRunPreparationIntent,
ObservatoryRunPreparationLedger,
ObservatoryRunPreparationNotFoundError,
load_observatory_run_preparation_ledger,
observatory_run_preparation_request_sha256,
)
from k1link.observatory.setups import (
LABORATORY_SETUP_CATALOG_SCHEMA,
LABORATORY_SETUP_REGISTRY_SCHEMA,
@@ -13,7 +31,23 @@ from k1link.observatory.setups import (
__all__ = [
"LABORATORY_SETUP_CATALOG_SCHEMA",
"LABORATORY_SETUP_REGISTRY_SCHEMA",
"MAX_RUN_PREPARATION_RECORDS",
"MAX_RUN_PREPARATION_STORAGE_BYTES",
"OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA",
"OBSERVATORY_RUN_PREPARATION_SCHEMA",
"RUN_PREPARATION_DATABASE_NAME",
"RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS",
"LaboratorySetupRegistry",
"LaboratorySetupRegistryError",
"ObservatoryRunPreparation",
"ObservatoryRunPreparationCapacityError",
"ObservatoryRunPreparationConflictError",
"ObservatoryRunPreparationError",
"ObservatoryRunPreparationIntegrityError",
"ObservatoryRunPreparationIntent",
"ObservatoryRunPreparationLedger",
"ObservatoryRunPreparationNotFoundError",
"is_admitted_observatory_recorded_result",
"load_observatory_run_preparation_ledger",
"observatory_run_preparation_request_sha256",
]
+712
View File
@@ -0,0 +1,712 @@
"""Durable, observation-only preparation receipts for Observatory runs.
This ledger deliberately does not model a run lifecycle. Mission Core does
not currently have a durable dispatcher for the archived Observatory
RunDefinition, so a preparation can only record the exact admitted input and
the fail-closed blocker. A real run ledger must be introduced together with
an atomic durable dispatch receipt; until then ``run_id`` remains absent.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sqlite3
import threading
from collections.abc import Callable, Iterator
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Final, Literal
from uuid import uuid4
from k1link.artifacts import utc_now_iso
OBSERVATORY_RUN_PREPARATION_SCHEMA: Final = (
"missioncore.observatory-run-preparation/v1"
)
OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA: Final = (
"missioncore.observatory-run-preparation-request/v1"
)
RUN_PREPARATION_DATABASE_NAME: Final = "observatory-run-preparations.sqlite3"
MAX_RUN_PREPARATION_RECORDS: Final = 10_000
MAX_RUN_PREPARATION_STORAGE_BYTES: Final = 64 * 1024 * 1024
RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS: Final = 0.1
_SQLITE_BUSY_TIMEOUT_MILLISECONDS: Final = 100
_PREPARATION_ID = re.compile(r"^observatory-prep-[a-f0-9]{32}$")
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
_SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
_STATE: Literal["blocked"] = "blocked"
_AUTHORITY: Final = {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
}
_TABLE_SQL = """
CREATE TABLE observatory_run_preparations (
preparation_id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
request_sha256 TEXT NOT NULL,
receipt_sha256 TEXT NOT NULL,
source_session_id TEXT NOT NULL,
source_catalog_sha256 TEXT NOT NULL,
setup_id TEXT NOT NULL,
definition_id TEXT NOT NULL,
definition_version INTEGER NOT NULL CHECK (definition_version > 0),
definition_sha256 TEXT NOT NULL,
state TEXT NOT NULL CHECK (state = 'blocked'),
blocker_code TEXT NOT NULL,
blocker_message TEXT NOT NULL,
created_at_utc TEXT NOT NULL
);
"""
_SCHEMA_SQL = _TABLE_SQL.replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1)
_EXPECTED_COLUMNS: Final = (
("preparation_id", "TEXT", 0, 1),
("idempotency_key", "TEXT", 1, 0),
("request_sha256", "TEXT", 1, 0),
("receipt_sha256", "TEXT", 1, 0),
("source_session_id", "TEXT", 1, 0),
("source_catalog_sha256", "TEXT", 1, 0),
("setup_id", "TEXT", 1, 0),
("definition_id", "TEXT", 1, 0),
("definition_version", "INTEGER", 1, 0),
("definition_sha256", "TEXT", 1, 0),
("state", "TEXT", 1, 0),
("blocker_code", "TEXT", 1, 0),
("blocker_message", "TEXT", 1, 0),
("created_at_utc", "TEXT", 1, 0),
)
class ObservatoryRunPreparationError(RuntimeError):
"""Base error for the isolated Observatory preparation ledger."""
class ObservatoryRunPreparationConflictError(ObservatoryRunPreparationError):
"""An idempotency key is already bound to another preparation request."""
class ObservatoryRunPreparationNotFoundError(ObservatoryRunPreparationError):
"""A preparation receipt is not present."""
class ObservatoryRunPreparationIntegrityError(ObservatoryRunPreparationError):
"""The durable ledger violates its fail-closed immutable contract."""
class ObservatoryRunPreparationCapacityError(ObservatoryRunPreparationError):
"""The bounded preparation ledger cannot admit another receipt."""
@dataclass(frozen=True, slots=True)
class ObservatoryRunPreparationIntent:
"""Validated, path-free identity captured at the preparation boundary."""
idempotency_key: str
source_session_id: str
source_catalog_sha256: str
setup_id: str
definition_id: str
definition_version: int
definition_sha256: str
blocker_code: str
blocker_message: str
def __post_init__(self) -> None:
_validate_idempotency_key(self.idempotency_key)
_validate_pattern(self.source_session_id, _SESSION_ID, "source session id")
_validate_digest(self.source_catalog_sha256, "source catalog sha256")
_validate_pattern(self.setup_id, _IDENTIFIER, "setup id")
_validate_pattern(self.definition_id, _IDENTIFIER, "definition id")
if (
not isinstance(self.definition_version, int)
or isinstance(self.definition_version, bool)
or self.definition_version < 1
):
raise ValueError("definition version must be positive")
_validate_digest(self.definition_sha256, "definition sha256")
_validate_pattern(self.blocker_code, _IDENTIFIER, "blocker code")
_validate_text(self.blocker_message, "blocker message", max_length=1_000)
@property
def request_sha256(self) -> str:
"""Fingerprint only the exact client payload used for idempotency."""
return observatory_run_preparation_request_sha256(
idempotency_key=self.idempotency_key,
source_session_id=self.source_session_id,
setup_id=self.setup_id,
definition_sha256=self.definition_sha256,
)
@dataclass(frozen=True, slots=True)
class ObservatoryRunPreparation:
preparation_id: str
idempotency_key: str
request_sha256: str
receipt_sha256: str
source_session_id: str
source_catalog_sha256: str
setup_id: str
definition_id: str
definition_version: int
definition_sha256: str
blocker_code: str
blocker_message: str
created_at_utc: str
state: Literal["blocked"] = _STATE
@classmethod
def from_intent(
cls,
intent: ObservatoryRunPreparationIntent,
*,
preparation_id: str,
created_at_utc: str,
) -> ObservatoryRunPreparation:
receipt_sha256 = _receipt_sha256(
preparation_id=preparation_id,
idempotency_key=intent.idempotency_key,
request_sha256=intent.request_sha256,
source_session_id=intent.source_session_id,
source_catalog_sha256=intent.source_catalog_sha256,
setup_id=intent.setup_id,
definition_id=intent.definition_id,
definition_version=intent.definition_version,
definition_sha256=intent.definition_sha256,
blocker_code=intent.blocker_code,
blocker_message=intent.blocker_message,
created_at_utc=created_at_utc,
)
return cls(
preparation_id=preparation_id,
idempotency_key=intent.idempotency_key,
request_sha256=intent.request_sha256,
receipt_sha256=receipt_sha256,
source_session_id=intent.source_session_id,
source_catalog_sha256=intent.source_catalog_sha256,
setup_id=intent.setup_id,
definition_id=intent.definition_id,
definition_version=intent.definition_version,
definition_sha256=intent.definition_sha256,
blocker_code=intent.blocker_code,
blocker_message=intent.blocker_message,
created_at_utc=created_at_utc,
)
def __post_init__(self) -> None:
_validate_pattern(self.preparation_id, _PREPARATION_ID, "preparation id")
intent = ObservatoryRunPreparationIntent(
idempotency_key=self.idempotency_key,
source_session_id=self.source_session_id,
source_catalog_sha256=self.source_catalog_sha256,
setup_id=self.setup_id,
definition_id=self.definition_id,
definition_version=self.definition_version,
definition_sha256=self.definition_sha256,
blocker_code=self.blocker_code,
blocker_message=self.blocker_message,
)
_validate_digest(self.request_sha256, "request sha256")
if self.request_sha256 != intent.request_sha256:
raise ObservatoryRunPreparationIntegrityError(
"stored preparation request fingerprint changed"
)
_validate_digest(self.receipt_sha256, "receipt sha256")
if self.state != _STATE:
raise ObservatoryRunPreparationIntegrityError(
"preparation cannot claim a run lifecycle state"
)
_validate_timestamp(self.created_at_utc)
if self.receipt_sha256 != _receipt_sha256(
preparation_id=self.preparation_id,
idempotency_key=self.idempotency_key,
request_sha256=self.request_sha256,
source_session_id=self.source_session_id,
source_catalog_sha256=self.source_catalog_sha256,
setup_id=self.setup_id,
definition_id=self.definition_id,
definition_version=self.definition_version,
definition_sha256=self.definition_sha256,
blocker_code=self.blocker_code,
blocker_message=self.blocker_message,
created_at_utc=self.created_at_utc,
):
raise ObservatoryRunPreparationIntegrityError(
"stored preparation receipt fingerprint changed"
)
def as_dict(self) -> dict[str, object]:
return {
"schema_version": OBSERVATORY_RUN_PREPARATION_SCHEMA,
"preparation_id": self.preparation_id,
"run_id": None,
"idempotency_key": self.idempotency_key,
"request_sha256": self.request_sha256,
"receipt_sha256": self.receipt_sha256,
"source": {
"session_id": self.source_session_id,
"catalog_sha256": self.source_catalog_sha256,
},
"setup": {
"setup_id": self.setup_id,
"definition": {
"definition_id": self.definition_id,
"version": self.definition_version,
"definition_sha256": self.definition_sha256,
},
},
"state": self.state,
"blocker": {
"reason_code": self.blocker_code,
"message": self.blocker_message,
},
"preconditions_passed": True,
"submission_allowed": False,
"dispatch_receipt": None,
"created_at_utc": self.created_at_utc,
"authority": dict(_AUTHORITY),
}
class ObservatoryRunPreparationLedger:
"""SQLite-backed immutable preparation receipts with durable idempotency."""
def __init__(
self,
data_dir: Path,
*,
clock: Callable[[], str] = utc_now_iso,
max_records: int = MAX_RUN_PREPARATION_RECORDS,
) -> None:
if (
not isinstance(max_records, int)
or isinstance(max_records, bool)
or not 1 <= max_records <= MAX_RUN_PREPARATION_RECORDS
):
raise ValueError("preparation ledger record quota is invalid")
self.data_dir = data_dir.expanduser().resolve()
self.database_path = self.data_dir / RUN_PREPARATION_DATABASE_NAME
self._clock = clock
self._max_records = max_records
self._lock = threading.RLock()
self._initialize()
def prepare(
self,
intent: ObservatoryRunPreparationIntent,
) -> tuple[ObservatoryRunPreparation, bool]:
"""Persist once, returning the original receipt for an exact retry."""
try:
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT * FROM observatory_run_preparations "
"WHERE idempotency_key = ?",
(intent.idempotency_key,),
).fetchone()
if existing is not None:
record = _record_from_row(existing)
if record.request_sha256 != intent.request_sha256:
raise ObservatoryRunPreparationConflictError(
"idempotency key is bound to another preparation request"
)
connection.commit()
return record, False
if self._bounded_record_count(connection) >= self._max_records:
raise ObservatoryRunPreparationCapacityError(
"preparation ledger record quota is exhausted"
)
record = ObservatoryRunPreparation.from_intent(
intent,
preparation_id=f"observatory-prep-{uuid4().hex}",
created_at_utc=self._clock(),
)
connection.execute(
"INSERT INTO observatory_run_preparations "
"(preparation_id, idempotency_key, request_sha256, receipt_sha256, "
"source_session_id, source_catalog_sha256, setup_id, "
"definition_id, definition_version, definition_sha256, "
"state, blocker_code, blocker_message, created_at_utc) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
record.preparation_id,
record.idempotency_key,
record.request_sha256,
record.receipt_sha256,
record.source_session_id,
record.source_catalog_sha256,
record.setup_id,
record.definition_id,
record.definition_version,
record.definition_sha256,
record.state,
record.blocker_code,
record.blocker_message,
record.created_at_utc,
),
)
connection.commit()
return record, True
except ObservatoryRunPreparationError:
raise
except (OSError, sqlite3.Error) as exc:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger write failed"
) from exc
def get(self, preparation_id: str) -> ObservatoryRunPreparation:
_validate_pattern(preparation_id, _PREPARATION_ID, "preparation id")
try:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT * FROM observatory_run_preparations "
"WHERE preparation_id = ?",
(preparation_id,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger read failed"
) from exc
if row is None:
raise ObservatoryRunPreparationNotFoundError(preparation_id)
return _record_from_row(row)
def get_by_idempotency_key(
self,
idempotency_key: str,
) -> ObservatoryRunPreparation | None:
"""Return a sealed receipt without consulting mutable source/catalog state."""
_validate_idempotency_key(idempotency_key)
try:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT * FROM observatory_run_preparations "
"WHERE idempotency_key = ?",
(idempotency_key,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger read failed"
) from exc
return None if row is None else _record_from_row(row)
def _initialize(self) -> None:
try:
self.data_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
self._validate_storage_paths()
with suppress(OSError):
self.data_dir.chmod(0o700)
with self._connect() as connection:
connection.executescript(_SCHEMA_SQL)
self._validate_schema(connection)
if self._bounded_record_count(connection) > self._max_records:
raise ObservatoryRunPreparationCapacityError(
"preparation ledger record quota is exceeded"
)
connection.commit()
self._validate_storage_paths(require_database=True)
with suppress(OSError):
self.database_path.chmod(0o600)
_fsync_directory(self.data_dir)
except ObservatoryRunPreparationError:
raise
except (OSError, sqlite3.Error) as exc:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger initialization failed"
) from exc
def _validate_schema(self, connection: sqlite3.Connection) -> None:
columns = connection.execute(
"SELECT name, type, \"notnull\", pk FROM pragma_table_info(?) "
"ORDER BY cid LIMIT ?",
("observatory_run_preparations", len(_EXPECTED_COLUMNS) + 1),
).fetchall()
actual_columns = tuple(
(row["name"], row["type"], row["notnull"], row["pk"])
for row in columns
)
if actual_columns != _EXPECTED_COLUMNS:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger schema is incompatible"
)
indexes = connection.execute(
"SELECT name, \"unique\", partial FROM pragma_index_list(?) "
"ORDER BY seq LIMIT 9",
("observatory_run_preparations",),
).fetchall()
if len(indexes) > 8:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger has an unsafe index set"
)
if not _has_full_binary_unique_index(
connection,
indexes,
"preparation_id",
):
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger lost receipt identity uniqueness"
)
if not _has_full_binary_unique_index(
connection,
indexes,
"idempotency_key",
):
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger lost idempotency uniqueness"
)
table = connection.execute(
"SELECT sql FROM sqlite_master "
"WHERE type = 'table' AND name = 'observatory_run_preparations' "
"LIMIT 1"
).fetchone()
if (
table is None
or not isinstance(table["sql"], str)
or _normalized_sql(table["sql"]) != _normalized_sql(_TABLE_SQL)
):
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger table contract is incompatible"
)
def _bounded_record_count(self, connection: sqlite3.Connection) -> int:
rows = connection.execute(
"SELECT 1 FROM observatory_run_preparations LIMIT ?",
(self._max_records + 1,),
).fetchall()
return len(rows)
def _validate_storage_paths(self, *, require_database: bool = False) -> None:
paths = (
self.database_path,
Path(f"{self.database_path}-wal"),
Path(f"{self.database_path}-shm"),
)
total_bytes = 0
for path in paths:
if path.is_symlink() or (path.exists() and not path.is_file()):
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger path is unsafe"
)
if path.exists():
total_bytes += path.stat().st_size
if require_database and not self.database_path.is_file():
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger database is unavailable"
)
if total_bytes > MAX_RUN_PREPARATION_STORAGE_BYTES:
raise ObservatoryRunPreparationCapacityError(
"preparation ledger storage quota is exceeded"
)
@contextmanager
def _connect(self) -> Iterator[sqlite3.Connection]:
self._validate_storage_paths()
connection = sqlite3.connect(
self.database_path,
timeout=RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS,
)
connection.row_factory = sqlite3.Row
try:
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA synchronous = FULL")
connection.execute(
f"PRAGMA busy_timeout = {_SQLITE_BUSY_TIMEOUT_MILLISECONDS}"
)
connection.execute("PRAGMA journal_mode = WAL")
yield connection
finally:
connection.close()
def _record_from_row(row: sqlite3.Row) -> ObservatoryRunPreparation:
try:
return ObservatoryRunPreparation(
preparation_id=row["preparation_id"],
idempotency_key=row["idempotency_key"],
request_sha256=row["request_sha256"],
receipt_sha256=row["receipt_sha256"],
source_session_id=row["source_session_id"],
source_catalog_sha256=row["source_catalog_sha256"],
setup_id=row["setup_id"],
definition_id=row["definition_id"],
definition_version=row["definition_version"],
definition_sha256=row["definition_sha256"],
state=row["state"],
blocker_code=row["blocker_code"],
blocker_message=row["blocker_message"],
created_at_utc=row["created_at_utc"],
)
except (IndexError, KeyError, TypeError, ValueError) as exc:
raise ObservatoryRunPreparationIntegrityError(
"stored preparation receipt is invalid"
) from exc
def load_observatory_run_preparation_ledger(
data_dir: Path,
) -> tuple[ObservatoryRunPreparationLedger | None, str | None]:
"""Open the optional ledger within a short, fixed startup lock budget."""
try:
return ObservatoryRunPreparationLedger(data_dir), None
except (ObservatoryRunPreparationError, OSError) as exc:
return None, str(exc)
def observatory_run_preparation_request_sha256(
*,
idempotency_key: str,
source_session_id: str,
setup_id: str,
definition_sha256: str,
) -> str:
"""Hash the exact immutable client payload, without mutable resolution state."""
_validate_idempotency_key(idempotency_key)
_validate_pattern(source_session_id, _SESSION_ID, "source session id")
_validate_pattern(setup_id, _IDENTIFIER, "setup id")
_validate_digest(definition_sha256, "definition sha256")
document = {
"schema_version": OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA,
"idempotency_key": idempotency_key,
"source_session_id": source_session_id,
"setup_id": setup_id,
"definition_sha256": definition_sha256,
}
return hashlib.sha256(_canonical_json(document)).hexdigest()
def _receipt_sha256(
*,
preparation_id: str,
idempotency_key: str,
request_sha256: str,
source_session_id: str,
source_catalog_sha256: str,
setup_id: str,
definition_id: str,
definition_version: int,
definition_sha256: str,
blocker_code: str,
blocker_message: str,
created_at_utc: str,
) -> str:
document = {
"schema_version": OBSERVATORY_RUN_PREPARATION_SCHEMA,
"preparation_id": preparation_id,
"idempotency_key": idempotency_key,
"request_sha256": request_sha256,
"source_session_id": source_session_id,
"source_catalog_sha256": source_catalog_sha256,
"setup_id": setup_id,
"definition_id": definition_id,
"definition_version": definition_version,
"definition_sha256": definition_sha256,
"state": _STATE,
"blocker_code": blocker_code,
"blocker_message": blocker_message,
"created_at_utc": created_at_utc,
}
return hashlib.sha256(_canonical_json(document)).hexdigest()
def _has_full_binary_unique_index(
connection: sqlite3.Connection,
indexes: list[sqlite3.Row],
column: str,
) -> bool:
for index in indexes:
if not bool(index["unique"]) or bool(index["partial"]):
continue
fields = connection.execute(
"SELECT name, coll, desc, key FROM pragma_index_xinfo(?) "
"ORDER BY seqno LIMIT 4",
(index["name"],),
).fetchall()
if len(fields) > 3:
continue
key_fields = [field for field in fields if bool(field["key"])]
if (
len(key_fields) == 1
and key_fields[0]["name"] == column
and key_fields[0]["coll"] == "BINARY"
and not bool(key_fields[0]["desc"])
):
return True
return False
def _normalized_sql(value: str) -> str:
return " ".join(value.split()).removesuffix(";")
def _validate_idempotency_key(value: object) -> None:
_validate_pattern(value, _IDEMPOTENCY_KEY, "idempotency key")
def _validate_digest(value: object, label: str) -> None:
_validate_pattern(value, _SHA256, label)
def _validate_pattern(value: object, pattern: re.Pattern[str], label: str) -> None:
if not isinstance(value, str) or pattern.fullmatch(value) is None:
raise ValueError(f"{label} is invalid")
def _validate_text(value: object, label: str, *, max_length: int) -> None:
if (
not isinstance(value, str)
or not value.strip()
or value != value.strip()
or len(value) > max_length
):
raise ValueError(f"{label} is invalid")
def _validate_timestamp(value: object) -> None:
_validate_text(value, "created timestamp", max_length=64)
assert isinstance(value, str)
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("created timestamp is invalid") from exc
if (
parsed.tzinfo is None
or parsed.utcoffset() != timedelta(0)
or not value.endswith("Z")
):
raise ValueError("created timestamp must use UTC")
def _canonical_json(value: object) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_DIRECTORY", 0)
descriptor = os.open(path, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
+16 -1
View File
@@ -36,7 +36,12 @@ from k1link.laboratory.m48_raw_evidence import (
M48RawEvidenceError,
M48RawEvidenceReader,
)
from k1link.observatory import LaboratorySetupRegistry, LaboratorySetupRegistryError
from k1link.observatory import (
LaboratorySetupRegistry,
LaboratorySetupRegistryError,
ObservatoryRunPreparationLedger,
load_observatory_run_preparation_ledger,
)
from k1link.sessions import (
MaterializedRecording,
RecordedCameraFrameService,
@@ -191,6 +196,8 @@ LABORATORY_RUNNER = LaboratoryRunner(
LABORATORY_VALUE_REVIEW_REGISTRY = LaboratoryValueReviewRegistry.from_file(
REPOSITORY_ROOT / "config" / "laboratory-value-review.json"
)
OBSERVATORY_LABORATORY_SETUP_REGISTRY: LaboratorySetupRegistry | None
OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR: str | None
try:
OBSERVATORY_LABORATORY_SETUP_REGISTRY = LaboratorySetupRegistry.from_file(
REPOSITORY_ROOT / "config" / "observatory-laboratory-setups.json",
@@ -229,6 +236,12 @@ plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
session_store = SessionStore(REPOSITORY_ROOT)
OBSERVATORY_RUN_PREPARATION_LEDGER: ObservatoryRunPreparationLedger | None
OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR: str | None
(
OBSERVATORY_RUN_PREPARATION_LEDGER,
OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR,
) = load_observatory_run_preparation_ledger(session_store.data_dir)
simulation_project_store = SimulationProjectStore(session_store.data_dir)
simulation_project_service = SimulationProjectService(simulation_project_store)
session_artifact_gateway = configured_artifact_gateway(session_store.data_dir)
@@ -710,6 +723,8 @@ app.include_router(
session_store,
setup_registry=OBSERVATORY_LABORATORY_SETUP_REGISTRY,
setup_registry_error=OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR,
run_preparation_ledger=OBSERVATORY_RUN_PREPARATION_LEDGER,
run_preparation_ledger_error=OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR,
)
)
app.include_router(
+267 -1
View File
@@ -3,13 +3,22 @@ from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Query, Response
from fastapi import Path as ApiPath
from pydantic import BaseModel, ConfigDict, Field
from k1link.observatory import (
LaboratorySetupRegistry,
ObservatoryRunPreparationCapacityError,
ObservatoryRunPreparationConflictError,
ObservatoryRunPreparationIntegrityError,
ObservatoryRunPreparationIntent,
ObservatoryRunPreparationLedger,
ObservatoryRunPreparationNotFoundError,
is_admitted_observatory_recorded_result,
observatory_run_preparation_request_sha256,
)
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
from k1link.sessions.models import SessionSummary
OBSERVATORY_PROJECTION_SCHEMA: Literal[
"missioncore.observatory-lab-projection/v1"
@@ -57,17 +66,41 @@ class ObservatoryRunPreflightRequest(_StrictApiModel):
definition_sha256: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
class ObservatoryRunPreparationRequest(_StrictApiModel):
schema_version: Literal[
"missioncore.observatory-run-preparation-request/v1"
]
idempotency_key: str = Field(
min_length=1,
max_length=160,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$",
)
source_session_id: str = Field(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
)
setup_id: str = Field(
min_length=3,
max_length=96,
pattern=r"^[a-z][a-z0-9-]{2,95}$",
)
definition_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
def build_observatory_router(
store: SessionStore,
*,
setup_registry: LaboratorySetupRegistry | None = None,
setup_registry_error: str | None = None,
run_preparation_ledger: ObservatoryRunPreparationLedger | None = None,
run_preparation_ledger_error: str | None = None,
) -> APIRouter:
"""Build bounded catalog-only mutations for typed Observatory projections."""
router = APIRouter(tags=["observatory"])
def source_summary(session_id: str):
def source_summary(session_id: str) -> SessionSummary:
try:
summary = store.get_session(session_id).summary
except SessionNotFoundError as exc:
@@ -89,6 +122,30 @@ def build_observatory_router(
)
return summary
def source_summary_with_catalog_snapshot(
session_id: str,
) -> tuple[SessionSummary, str]:
try:
detail, snapshot_sha256 = store.get_session_with_catalog_snapshot(session_id)
except SessionNotFoundError as exc:
raise HTTPException(status_code=404, detail="Исходная сессия не найдена.") from exc
except ValueError as exc:
raise HTTPException(
status_code=422,
detail="Некорректный идентификатор исходной сессии.",
) from exc
except SessionIntegrityError as exc:
raise HTTPException(
status_code=409,
detail="Каталог исходной сессии нарушил контракт целостности.",
) from exc
if detail.summary.lab is not None:
raise HTTPException(
status_code=409,
detail="Для подготовки нужна исходная, а не лабораторная сессия.",
)
return detail.summary, snapshot_sha256
def available_observatory_results(source_session_id: str) -> frozenset[str]:
if setup_registry is None:
return frozenset()
@@ -242,6 +299,215 @@ def build_observatory_router(
detail="Каталог сетапов Обсерватории не прошёл проверку целостности.",
)
if setup_registry is not None and run_preparation_ledger is not None:
@router.post("/api/v1/observatory/run-preparations")
def prepare_observatory_run(
request: ObservatoryRunPreparationRequest,
) -> dict[str, object]:
try:
existing = run_preparation_ledger.get_by_idempotency_key(
request.idempotency_key
)
request_sha256 = observatory_run_preparation_request_sha256(
idempotency_key=request.idempotency_key,
source_session_id=request.source_session_id,
setup_id=request.setup_id,
definition_sha256=request.definition_sha256,
)
except ObservatoryRunPreparationCapacityError as exc:
raise HTTPException(
status_code=503,
detail="Квота журнала подготовки расчётов исчерпана.",
) from exc
except (ObservatoryRunPreparationIntegrityError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Журнал подготовки расчётов недоступен.",
) from exc
if existing is not None:
if existing.request_sha256 != request_sha256:
raise HTTPException(
status_code=409,
detail="Ключ идемпотентности уже связан с другой подготовкой.",
)
return existing.as_dict()
source, source_catalog_sha256 = source_summary_with_catalog_snapshot(
request.source_session_id
)
try:
setup_registry.setup(request.setup_id)
except KeyError as exc:
raise HTTPException(
status_code=404,
detail="Сетап лаборатории не найден.",
) from exc
catalog = setup_registry.catalog(
source,
available_observatory_result_ids=available_observatory_results(
request.source_session_id
),
)
setups = catalog.get("setups")
if not isinstance(setups, list):
raise HTTPException(
status_code=503,
detail="Каталог сетапов Обсерватории нарушил контракт.",
)
projected = next(
(
item
for item in setups
if isinstance(item, dict) and item.get("setup_id") == request.setup_id
),
None,
)
if projected is None:
raise HTTPException(
status_code=503,
detail="Каталог сетапов Обсерватории нарушил контракт.",
)
definition = projected.get("run_definition")
if not isinstance(definition, dict):
raise HTTPException(
status_code=409,
detail="Для сетапа нет запускаемой RunDefinition.",
)
expected_digest = definition.get("definition_sha256")
if request.definition_sha256 != expected_digest:
raise HTTPException(
status_code=409,
detail="Идентичность RunDefinition изменилась; обновите каталог.",
)
compatibility = projected.get("compatibility")
preflight = projected.get("preflight")
executor = projected.get("executor")
if (
not isinstance(compatibility, dict)
or not isinstance(preflight, dict)
or not isinstance(executor, dict)
):
raise HTTPException(
status_code=503,
detail="Каталог сетапов Обсерватории нарушил контракт.",
)
if compatibility.get("compatible") is not True:
raise HTTPException(
status_code=409,
detail="Исходная сессия несовместима с выбранным сетапом.",
)
if preflight.get("outcome") == "existing":
raise HTTPException(
status_code=409,
detail="Точный результат уже существует; новый расчёт не подготовлен.",
)
if (
preflight.get("submission_allowed") is not False
or executor.get("state") != "not-installed"
):
raise HTTPException(
status_code=503,
detail="Dispatch-контракт Обсерватории не установлен.",
)
definition_id = definition.get("definition_id")
definition_version = definition.get("version")
blocker_code = executor.get("reason_code")
blocker_message = executor.get("reason")
if (
not isinstance(definition_id, str)
or not isinstance(definition_version, int)
or isinstance(definition_version, bool)
or not isinstance(expected_digest, str)
or not isinstance(blocker_code, str)
or not isinstance(blocker_message, str)
):
raise HTTPException(
status_code=503,
detail="Каталог сетапов Обсерватории нарушил контракт.",
)
try:
intent = ObservatoryRunPreparationIntent(
idempotency_key=request.idempotency_key,
source_session_id=request.source_session_id,
source_catalog_sha256=source_catalog_sha256,
setup_id=request.setup_id,
definition_id=definition_id,
definition_version=definition_version,
definition_sha256=expected_digest,
blocker_code=blocker_code,
blocker_message=blocker_message,
)
preparation, _created = run_preparation_ledger.prepare(intent)
except ObservatoryRunPreparationConflictError as exc:
raise HTTPException(
status_code=409,
detail="Ключ идемпотентности уже связан с другой подготовкой.",
) from exc
except ObservatoryRunPreparationCapacityError as exc:
raise HTTPException(
status_code=503,
detail="Квота журнала подготовки расчётов исчерпана.",
) from exc
except (ObservatoryRunPreparationIntegrityError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Журнал подготовки расчётов недоступен.",
) from exc
return preparation.as_dict()
@router.get("/api/v1/observatory/run-preparations/{preparation_id}")
def get_observatory_run_preparation(
preparation_id: str = ApiPath(
min_length=49,
max_length=49,
pattern=r"^observatory-prep-[a-f0-9]{32}$",
),
) -> dict[str, object]:
try:
return run_preparation_ledger.get(preparation_id).as_dict()
except ObservatoryRunPreparationNotFoundError as exc:
raise HTTPException(
status_code=404,
detail="Подготовка расчёта не найдена.",
) from exc
except ObservatoryRunPreparationCapacityError as exc:
raise HTTPException(
status_code=503,
detail="Квота журнала подготовки расчётов исчерпана.",
) from exc
except (ObservatoryRunPreparationIntegrityError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Журнал подготовки расчётов недоступен.",
) from exc
elif setup_registry_error is not None or run_preparation_ledger_error is not None:
@router.post("/api/v1/observatory/run-preparations")
def unavailable_observatory_run_preparation(
request: ObservatoryRunPreparationRequest,
) -> None:
del request
raise HTTPException(
status_code=503,
detail="Подготовка расчётов Обсерватории недоступна.",
)
@router.get("/api/v1/observatory/run-preparations/{preparation_id}")
def unavailable_observatory_run_preparation_receipt(
preparation_id: str = ApiPath(
min_length=49,
max_length=49,
pattern=r"^observatory-prep-[a-f0-9]{32}$",
),
) -> None:
del preparation_id
raise HTTPException(
status_code=503,
detail="Подготовка расчётов Обсерватории недоступна.",
)
@router.patch(
"/api/v1/observatory/lab-projections/{session_id}",
response_model=ObservatoryProjectionDocument,