diff --git a/devtools/affordance_usage.py b/devtools/affordance_usage.py index bc0e9d9266..97bd131128 100644 --- a/devtools/affordance_usage.py +++ b/devtools/affordance_usage.py @@ -14,6 +14,7 @@ from sqlite3 import Connection from typing import Any, cast +from devtools.index_snapshot import data_version, open_index_file_set, snapshot_identity, snapshot_index_file_set from polylogue.config import Config, get_config from polylogue.insights.affordance_usage import ( DEFAULT_FAMILY_PATTERNS, @@ -37,6 +38,14 @@ ) from polylogue.storage.sqlite.connection_profile import open_readonly_connection +_data_version = data_version +_snapshot_identity = snapshot_identity +_snapshot_observation = snapshot_index_file_set + + +class _DivergentSelectedIndexError(RuntimeError): + """The product route opened a physical index other than the selected evidence.""" + @dataclass(frozen=True, slots=True) class AffordanceUsageArgs: @@ -48,6 +57,7 @@ class AffordanceUsageArgs: sample_limit: int json: bool all_time: bool + index_db: Path | None = None def _parser() -> argparse.ArgumentParser: @@ -56,6 +66,12 @@ def _parser() -> argparse.ArgumentParser: description="Analyze agent affordance/tool usage from archive tool-use rows.", ) parser.add_argument("--archive-root", type=Path, default=None, help="Override the active archive root.") + parser.add_argument( + "--index-db", + type=Path, + default=None, + help="Read a specific candidate/live index database instead of /index.db.", + ) parser.add_argument("--out-dir", type=Path, default=None, help="Write CSV artifacts and report JSON.") parser.add_argument("--days", type=int, default=7, help="Recent window in days for adoption-sensitive counts.") parser.add_argument( @@ -190,6 +206,9 @@ def _demo_summary(report: dict[str, Any]) -> dict[str, Any]: "artifact": "agent-affordance-usage", "updated_at": report["captured_at"], "archive_root": report["archive_root"], + "evidence_root": report["evidence_root"], + "index_db": report["index_db"], + "snapshot_identity": report["snapshot_identity"], "index_schema_version": report["index_schema_version"], "claim": ( "Polylogue can compare agent affordance usage across normalized action evidence " @@ -860,12 +879,20 @@ def _try_product_detail_report( args: AffordanceUsageArgs, config: Config, conn: Connection, + opened_main_fd: int, recent_cutoff_ms: int, effective_detail_patterns: tuple[str, ...], ) -> dict[str, Any] | None: if not effective_detail_patterns or args.family: return None try: + selected_index_db = config.db_path.resolve(strict=True) + if selected_index_db != (config.archive_root / "index.db").resolve(): + # ArchiveStore opens exactly /index.db. Any other + # selected candidate, including a sibling file in the same root, must + # stay on the direct read-only SQLite fallback so its counts and + # snapshot identity cannot describe different databases. + return None from polylogue.insights.tool_usage import ToolUsageInsightQuery from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore except Exception: @@ -883,7 +910,16 @@ def _try_product_detail_report( since_ms = None if args.all_time else recent_cutoff_ms action_scope = "product-action-evidence-all-time" if args.all_time else "product-action-evidence-recent-window" try: - with ArchiveStore.open_existing(config.archive_root) as archive: + with ArchiveStore.open_existing( + config.archive_root, + index_path=selected_index_db, + opened_main_fd=opened_main_fd, + ) as archive: + opened_index_db = Path(archive.index_db_path).resolve(strict=True) + if opened_index_db != selected_index_db: + raise _DivergentSelectedIndexError( + "ArchiveStore opened a different physical index than the selected affordance evidence database" + ) merged_rows: dict[tuple[str, str, str, str, str, str], dict[str, object]] = {} for family, patterns in pattern_groups.items(): rows = archive.list_tool_action_evidence_count_rows( @@ -910,6 +946,8 @@ def _try_product_detail_report( bucket["normalized_tool_name"] = str( bucket.get("normalized_tool_name") or f"{family}/command-detail" ) + except _DivergentSelectedIndexError: + raise except Exception: return None rows = sorted( @@ -1177,13 +1215,38 @@ def _all_time_action_rows( def build_report(args: AffordanceUsageArgs) -> dict[str, Any]: config = _config_with_archive_root(get_config(), args.archive_root) - index_db = config.db_path + index_db = (args.index_db or config.db_path).expanduser().resolve() + config = Config( + archive_root=config.archive_root, + render_root=config.render_root, + sources=config.sources, + db_path=index_db, + drive_config=config.drive_config, + index_config=config.index_config, + ) where_sql, where_params = _where_for_filters(args.family, args.detail_pattern, alias="a") effective_tool_patterns = _clean_patterns(args.family or (() if args.detail_pattern else DEFAULT_FAMILY_PATTERNS)) effective_detail_patterns = _clean_patterns(args.detail_pattern) recent_cutoff_ms = _recent_cutoff_ms(args.days) - conn = open_readonly_connection(index_db) + opened_index_files = open_index_file_set(index_db) + opened_file_set = opened_index_files.__enter__() + opened_main_fd = opened_file_set.main_fd + conn: Connection | None = None + observer: Connection | None = None try: + conn = open_readonly_connection(index_db, opened_main_fd=opened_main_fd) + opened_file_set.capture_sidecars(index_db) + observer = open_readonly_connection(index_db, opened_main_fd=opened_main_fd) + assert conn is not None + observer_data_version_before = _data_version(observer) + opened_file_set.capture_sidecars(index_db) + conn.execute("BEGIN") + index_schema_version = _user_version(conn) + snapshot_before = _snapshot_observation( + index_db, + opened_main_fd=opened_main_fd, + opened_sidecar_fds=dict(opened_file_set.sidecar_fds), + ) origin_counts = _rows( conn, "SELECT origin, COUNT(*) AS sessions FROM sessions GROUP BY origin ORDER BY sessions DESC", @@ -1192,6 +1255,7 @@ def build_report(args: AffordanceUsageArgs) -> dict[str, Any]: args=args, config=config, conn=conn, + opened_main_fd=opened_main_fd, recent_cutoff_ms=recent_cutoff_ms, effective_detail_patterns=effective_detail_patterns, ) @@ -1262,7 +1326,7 @@ def build_report(args: AffordanceUsageArgs) -> dict[str, Any]: "command": "devtools workspace affordance-usage", "archive_root": str(config.archive_root), "index_db": str(index_db), - "index_schema_version": _user_version(conn), + "index_schema_version": index_schema_version, "patterns": list(args.family or (() if args.detail_pattern else DEFAULT_FAMILY_PATTERNS)), "detail_patterns": list(args.detail_pattern), "action_scope": action_scope, @@ -1280,8 +1344,29 @@ def build_report(args: AffordanceUsageArgs) -> dict[str, Any]: surface_summary = _surface_inventory_summary(surface_inventory) report["surface_inventory"] = surface_inventory report["surface_inventory_summary"] = surface_summary + opened_file_set.capture_sidecars(index_db) + snapshot_after = _snapshot_observation( + index_db, + opened_main_fd=opened_main_fd, + opened_sidecar_fds=dict(opened_file_set.sidecar_fds), + ) + observer_data_version_after = _data_version(observer) finally: - conn.close() + if observer is not None: + observer.close() + if conn is not None: + conn.close() + opened_index_files.__exit__(None, None, None) + report["archive_root"] = str(config.archive_root) + report["evidence_root"] = str(index_db.parent) + report["index_db"] = str(index_db) + report["snapshot_identity"] = _snapshot_identity( + index_db, + snapshot_before, + snapshot_after, + observer_data_version_before=observer_data_version_before, + observer_data_version_after=observer_data_version_after, + ) if args.out_dir is not None: out_dir = args.out_dir.expanduser() out_dir.mkdir(parents=True, exist_ok=True) @@ -1316,7 +1401,11 @@ def _write_readme(path: Path, report: dict[str, Any]) -> None: "# Agent Affordance Usage", "", f"Generated: {report['captured_at']}", - f"Archive root: `{report['archive_root']}`", + f"Configured archive root: `{report['archive_root']}`", + f"Evidence root: `{report['evidence_root']}`", + f"Evidence index: `{report['index_db']}`", + f"Evidence snapshot SHA-256: `{report['snapshot_identity']['sha256']}`", + f"Evidence snapshot stable: `{str(report['snapshot_identity']['stable']).lower()}`", f"Index schema: v{report['index_schema_version']}", f"Action scope: `{report['action_scope']}`", "", @@ -1385,6 +1474,7 @@ def main(argv: list[str] | None = None) -> int: report = build_report( AffordanceUsageArgs( archive_root=parsed.archive_root, + index_db=parsed.index_db, out_dir=parsed.out_dir, days=parsed.days, family=tuple(parsed.family or ()), diff --git a/devtools/index_snapshot.py b/devtools/index_snapshot.py new file mode 100644 index 0000000000..730adeaa49 --- /dev/null +++ b/devtools/index_snapshot.py @@ -0,0 +1,310 @@ +"""Shared selected-index file-set observation for evidence reports.""" + +from __future__ import annotations + +import hashlib +import json +import os +import stat +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from sqlite3 import Connection +from typing import Any + +_SNAPSHOT_HASH_CHUNK_BYTES = 1024 * 1024 + + +class IndexSnapshotRaceError(RuntimeError): + """The selected index pathname no longer names the opened database.""" + + +class IndexSnapshotUnsafeSidecarError(RuntimeError): + """A selected SQLite sidecar is not a safe regular file.""" + + +@dataclass(slots=True) +class OpenedIndexFileSet: + """Descriptors retained while SQLite and evidence observe one index.""" + + main_fd: int + sidecar_fds: dict[str, int] + _descriptors: list[int] = field(repr=False) + + def capture_sidecars(self, index_db: Path) -> None: + """Retain every safe sidecar currently visible after SQLite opens.""" + for suffix in ("-wal", "-shm", "-journal"): + path = Path(f"{index_db}{suffix}") + try: + path_metadata = path.stat(follow_symlinks=False) + except FileNotFoundError: + continue + if suffix in self.sidecar_fds: + handle_metadata = os.fstat(self.sidecar_fds[suffix]) + if (path_metadata.st_dev, path_metadata.st_ino) != ( + handle_metadata.st_dev, + handle_metadata.st_ino, + ): + raise IndexSnapshotUnsafeSidecarError(f"selected index sidecar was replaced: {path}") + continue + descriptor = _open_regular_index_file(path, sidecar=True) + self._descriptors.append(descriptor) + self.sidecar_fds[suffix] = descriptor + + +def _open_regular_index_file(path: Path, *, sidecar: bool, missing_ok: bool = False) -> int: + """Open one selected index object without blocking on non-regular files.""" + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + try: + descriptor = os.open(path, flags) + except FileNotFoundError: + if missing_ok: + raise + error = IndexSnapshotUnsafeSidecarError if sidecar else IndexSnapshotRaceError + label = "selected index sidecar" if sidecar else "selected index" + raise error(f"cannot open {label} safely: {path}") from None + except OSError as exc: + error = IndexSnapshotUnsafeSidecarError if sidecar else IndexSnapshotRaceError + label = "selected index sidecar" if sidecar else "selected index" + raise error(f"cannot open {label} safely: {path}") from exc + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + error = IndexSnapshotUnsafeSidecarError if sidecar else IndexSnapshotRaceError + label = "selected index sidecar" if sidecar else "selected index" + raise error(f"{label} is not a regular file: {path}") + return descriptor + except BaseException: + os.close(descriptor) + raise + + +@contextmanager +def open_index_file_set(index_db: Path) -> Iterator[OpenedIndexFileSet]: + """Open the selected database and existing sidecars without following links.""" + descriptors: list[int] = [] + try: + main_fd = _open_regular_index_file(index_db, sidecar=False) + descriptors.append(main_fd) + file_set = OpenedIndexFileSet(main_fd=main_fd, sidecar_fds={}, _descriptors=descriptors) + file_set.capture_sidecars(index_db) + yield file_set + finally: + for descriptor in reversed(descriptors): + os.close(descriptor) + + +@contextmanager +def open_index_file_handle(index_db: Path) -> Iterator[int]: + """Keep the selected main database inode open for evidence snapshots.""" + with open_index_file_set(index_db) as file_set: + yield file_set.main_fd + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(_SNAPSHOT_HASH_CHUNK_BYTES), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _file_sha256_descriptor(descriptor: int) -> str: + digest = hashlib.sha256() + offset = 0 + while True: + chunk = os.pread(descriptor, _SNAPSHOT_HASH_CHUNK_BYTES, offset) + if not chunk: + return digest.hexdigest() + digest.update(chunk) + offset += len(chunk) + + +def data_version(conn: Connection) -> int: + """Read SQLite's connection-local change marker for snapshot stability.""" + row = conn.execute("PRAGMA data_version").fetchone() + return int(row[0]) if row else 0 + + +def snapshot_identity( + index_db: Path, + before: dict[str, object], + after: dict[str, object], + *, + observer_data_version_before: int, + observer_data_version_after: int, +) -> dict[str, object]: + """Build the canonical report identity from two file-set observations.""" + observation_complete = bool(before.get("observation_complete") and after.get("observation_complete")) + file_set_stable = before.get("sha256") == after.get("sha256") + no_concurrent_commits = observer_data_version_before == observer_data_version_after + return { + "index_db": str(index_db), + "sha256": before.get("sha256"), + "size": before.get("size"), + "before": before, + "after": after, + "observation_complete": observation_complete, + "file_set_stable": file_set_stable, + "observer_data_version_before": observer_data_version_before, + "observer_data_version_after": observer_data_version_after, + "no_concurrent_commits": no_concurrent_commits, + "stable": bool(observation_complete and file_set_stable and no_concurrent_commits), + } + + +def snapshot_index_file_set( + index_db: Path, + *, + opened_main_fd: int | None = None, + opened_sidecar_fds: Mapping[str, int] | None = None, +) -> dict[str, Any]: + """Capture one selected index and its SQLite sidecars under one contract. + + Each present file is hashed between two metadata reads. A disappearing or + changing file makes ``observation_complete`` false, while the file-set + digest remains useful evidence for the caller's stability comparison. + """ + paths = (index_db, Path(f"{index_db}-wal"), Path(f"{index_db}-shm"), Path(f"{index_db}-journal")) + files: list[dict[str, Any]] = [] + complete = True + for path in paths: + suffix = "" if path == index_db else path.name.removeprefix(index_db.name) + if path != index_db and opened_sidecar_fds is not None and suffix not in opened_sidecar_fds: + try: + sidecar_metadata = path.stat(follow_symlinks=False) + except FileNotFoundError: + files.append({"path": str(path), "present": False}) + continue + if not stat.S_ISREG(sidecar_metadata.st_mode): + raise IndexSnapshotUnsafeSidecarError(f"selected index sidecar is not a regular file: {path}") + late_sidecar_fd = _open_regular_index_file(path, sidecar=True) + try: + late_sidecar_metadata = os.fstat(late_sidecar_fd) + late_sidecar_digest = _file_sha256_descriptor(late_sidecar_fd) + finally: + os.close(late_sidecar_fd) + complete = False + files.append( + { + "path": str(path), + "present": True, + "size": late_sidecar_metadata.st_size, + "mtime_ns": late_sidecar_metadata.st_mtime_ns, + "inode": late_sidecar_metadata.st_ino, + "sha256": late_sidecar_digest, + "changed_during_observation": True, + } + ) + continue + opened_fd = opened_main_fd if path == index_db else (opened_sidecar_fds or {}).get(suffix) + if opened_fd is not None: + handle_metadata = os.fstat(opened_fd) + try: + path_metadata_before = path.stat(follow_symlinks=False) + except FileNotFoundError: + complete = False + else: + if not stat.S_ISREG(path_metadata_before.st_mode): + error = IndexSnapshotRaceError if path == index_db else IndexSnapshotUnsafeSidecarError + raise error(f"selected index file is not regular: {path}") + if (path_metadata_before.st_dev, path_metadata_before.st_ino) != ( + handle_metadata.st_dev, + handle_metadata.st_ino, + ): + label = "selected index path" if path == index_db else "selected index sidecar" + error = IndexSnapshotRaceError if path == index_db else IndexSnapshotUnsafeSidecarError + raise error(f"{label} was replaced while its reader was open: {path}") + digest = _file_sha256_descriptor(opened_fd) + try: + path_metadata_after = path.stat(follow_symlinks=False) + except FileNotFoundError: + complete = False + path_present = False + else: + if not stat.S_ISREG(path_metadata_after.st_mode): + error = IndexSnapshotRaceError if path == index_db else IndexSnapshotUnsafeSidecarError + raise error(f"selected index file is not regular: {path}") + if (path_metadata_after.st_dev, path_metadata_after.st_ino) != ( + handle_metadata.st_dev, + handle_metadata.st_ino, + ): + label = "selected index path" if path == index_db else "selected index sidecar" + error = IndexSnapshotRaceError if path == index_db else IndexSnapshotUnsafeSidecarError + raise error(f"{label} was replaced during snapshot observation: {path}") + path_present = True + files.append( + { + "path": str(path), + "present": path_present, + "size": handle_metadata.st_size, + "mtime_ns": handle_metadata.st_mtime_ns, + "inode": handle_metadata.st_ino, + "sha256": digest, + "changed_during_observation": False, + } + ) + continue + try: + safe_fd = _open_regular_index_file(path, sidecar=path != index_db, missing_ok=True) + except FileNotFoundError: + if path == index_db: + complete = False + files.append({"path": str(path), "present": False}) + continue + try: + metadata_before = os.fstat(safe_fd) + path_metadata_before = path.stat(follow_symlinks=False) + if (path_metadata_before.st_dev, path_metadata_before.st_ino) != ( + metadata_before.st_dev, + metadata_before.st_ino, + ): + error = IndexSnapshotRaceError if path == index_db else IndexSnapshotUnsafeSidecarError + raise error(f"selected index file changed before hashing: {path}") + digest = _file_sha256_descriptor(safe_fd) + metadata_after = path.stat(follow_symlinks=False) + except FileNotFoundError: + complete = False + files.append({"path": str(path), "present": False, "changed_during_observation": True}) + continue + finally: + os.close(safe_fd) + unchanged = ( + metadata_before.st_dev, + metadata_before.st_ino, + metadata_before.st_size, + metadata_before.st_mtime_ns, + ) == ( + metadata_after.st_dev, + metadata_after.st_ino, + metadata_after.st_size, + metadata_after.st_mtime_ns, + ) + complete = complete and unchanged + files.append( + { + "path": str(path), + "present": True, + "size": metadata_after.st_size, + "mtime_ns": metadata_after.st_mtime_ns, + "inode": metadata_after.st_ino, + "sha256": digest, + "changed_during_observation": not unchanged, + } + ) + digest_files = [ + ({key: value for key, value in file.items() if key != "sha256"} if path.name.endswith("-shm") else file) + for path, file in zip(paths, files, strict=True) + ] + encoded = json.dumps(digest_files, sort_keys=True, separators=(",", ":")).encode("utf-8") + main = files[0] + return { + "path": str(index_db), + "index_db": str(index_db), + "present": main["present"], + "size": main.get("size"), + "files": files, + "observation_complete": complete, + "sha256": hashlib.sha256(encoded).hexdigest(), + } diff --git a/devtools/lineage_validation.py b/devtools/lineage_validation.py index 8d9f2116f2..2c797eedfc 100644 --- a/devtools/lineage_validation.py +++ b/devtools/lineage_validation.py @@ -13,17 +13,34 @@ from sqlite3 import Connection from typing import Any, cast +from devtools.index_snapshot import data_version, open_index_file_set, snapshot_identity, snapshot_index_file_set from polylogue.config import Config, get_config from polylogue.storage.sqlite.archive_tiers.write import read_archive_session_envelope from polylogue.storage.sqlite.connection_profile import open_readonly_connection +_data_version = data_version +_snapshot_identity = snapshot_index_file_set +_snapshot_report_identity = snapshot_identity + SUPPORTED_PREFIX_ORIGINS = frozenset({"codex-session", "claude-code-session"}) REQUIRED_SESSION_LINK_COLUMNS = frozenset({"branch_point_message_id", "inheritance"}) REQUIRED_TOPOLOGY_LINK_COLUMNS = frozenset( {"dst_native_id", "evidence_json", "link_type", "method", "resolved_dst_session_id", "status"} ) TOPOLOGY_EFFECTIVE_STATES = frozenset({"resolved", "unresolved", "repaired", "quarantined"}) -_SNAPSHOT_HASH_CHUNK_BYTES = 1024 * 1024 +_EFFECTIVE_UNRESOLVED_LINK_PREDICATE = """ + l.resolved_dst_session_id IS NULL + AND COALESCE(NULLIF(TRIM(l.status), ''), 'unresolved') = 'unresolved' + AND NOT EXISTS ( + SELECT 1 + FROM session_links resolved + WHERE resolved.src_session_id = l.src_session_id + AND resolved.inheritance = 'prefix-sharing' + AND resolved.resolved_dst_session_id IS NOT NULL + AND resolved.branch_point_message_id IS NOT NULL + AND COALESCE(NULLIF(TRIM(resolved.status), ''), 'unresolved') != 'quarantined' + ) +""" @dataclass(frozen=True, slots=True) @@ -37,42 +54,6 @@ class LineageValidationArgs: index_db: Path | None = None -def _snapshot_identity(index_db: Path) -> dict[str, Any]: - """Describe the database files that make up one read-only index snapshot.""" - paths = [index_db, Path(f"{index_db}-wal"), Path(f"{index_db}-shm"), Path(f"{index_db}-journal")] - files: list[dict[str, Any]] = [] - for path in paths: - if not path.is_file(): - files.append({"path": str(path), "present": False}) - continue - stat = path.stat() - digest = _file_sha256(path) - files.append( - { - "path": str(path), - "present": True, - "size": stat.st_size, - "mtime_ns": stat.st_mtime_ns, - "inode": stat.st_ino, - "sha256": digest, - } - ) - encoded = json.dumps(files, sort_keys=True, separators=(",", ":")).encode("utf-8") - return { - "index_db": str(index_db), - "files": files, - "sha256": hashlib.sha256(encoded).hexdigest(), - } - - -def _file_sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for chunk in iter(lambda: stream.read(_SNAPSHOT_HASH_CHUNK_BYTES), b""): - digest.update(chunk) - return digest.hexdigest() - - def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="devtools workspace lineage-validation", @@ -130,12 +111,6 @@ def _user_version(conn: Connection) -> int: return int(row[0]) if row else 0 -def _data_version(conn: Connection) -> int: - """Return this observer connection's external-commit generation.""" - row = conn.execute("PRAGMA data_version").fetchone() - return int(row[0]) if row else 0 - - def _count(conn: Connection, table: str) -> int: row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone() return int(row[0]) if row else 0 @@ -328,9 +303,17 @@ def _topology_read_sample(conn: Connection, *, limit: int) -> dict[str, Any]: AND COALESCE(NULLIF(TRIM(status), ''), 'unresolved') = 'unresolved' """, ) + effective_unresolved_count = _scalar_int( + conn, + f""" + SELECT COUNT(*) + FROM session_links l + WHERE {_EFFECTIVE_UNRESOLVED_LINK_PREDICATE} + """, + ) rows = _rows( conn, - """ + f""" SELECT l.src_session_id AS session_id, l.dst_origin AS parent_origin, l.dst_native_id AS parent_native_id, @@ -338,8 +321,7 @@ def _topology_read_sample(conn: Connection, *, limit: int) -> dict[str, Any]: COUNT(DISTINCT m.message_id) AS stored_messages FROM session_links l LEFT JOIN messages m ON m.session_id = l.src_session_id - WHERE l.resolved_dst_session_id IS NULL - AND COALESCE(NULLIF(TRIM(l.status), ''), 'unresolved') = 'unresolved' + WHERE {_EFFECTIVE_UNRESOLVED_LINK_PREDICATE} GROUP BY l.src_session_id, l.dst_origin, l.dst_native_id, l.link_type ORDER BY l.src_session_id, l.dst_origin, l.dst_native_id, l.link_type LIMIT ? @@ -374,7 +356,7 @@ def _topology_read_sample(conn: Connection, *, limit: int) -> dict[str, Any]: } ) unsafe = sum(1 for row in samples if row.get("read_status") != "safe") - if unresolved_count == 0: + if effective_unresolved_count == 0: status = "not_applicable" safe = True elif not samples: @@ -389,6 +371,7 @@ def _topology_read_sample(conn: Connection, *, limit: int) -> dict[str, Any]: return { "requested": limit, "unresolved_count": unresolved_count, + "effective_unresolved_count": effective_unresolved_count, "sampled": len(samples), "status": status, "safe": safe, @@ -431,9 +414,11 @@ def census_topology_links(conn: Connection, *, sample_unresolved: int = 20) -> d "quarantined_with_resolved_parent_count": 0, "quarantined_with_stale_projection_count": 0, "unresolved_count": 0, + "effective_unresolved_count": 0, "unresolved_read_sample": { "requested": sample_unresolved, "unresolved_count": 0, + "effective_unresolved_count": 0, "sampled": 0, "status": "not_observed", "safe": False, @@ -532,6 +517,7 @@ def census_topology_links(conn: Connection, *, sample_unresolved: int = 20) -> d "quarantined_with_resolved_parent_count": quarantined_with_resolved_parent_count, "quarantined_with_stale_projection_count": quarantined_with_stale_projection_count, "unresolved_count": unresolved_read_sample["unresolved_count"], + "effective_unresolved_count": unresolved_read_sample["effective_unresolved_count"], "unresolved_read_sample": unresolved_read_sample, } @@ -717,6 +703,8 @@ def _demo_summary(report: dict[str, Any]) -> dict[str, Any]: "artifact": "lineage-validation", "updated_at": report["captured_at"], "archive_root": report["archive_root"], + "index_db": report["index_db"], + "snapshot_identity": report["snapshot_identity"], "index_schema_version": report["index_schema_version"], "claim": ( "Polylogue can emit a read-only lineage validation artifact that separates physical stored " @@ -764,6 +752,11 @@ def _write_readme(path: Path, report: dict[str, Any]) -> None: "", "Generated by `devtools workspace lineage-validation`.", "", + f"Configured archive root: `{report['archive_root']}`", + f"Evidence index: `{report['index_db']}`", + f"Evidence snapshot SHA-256: `{report['snapshot_identity']['sha256']}`", + f"Evidence snapshot stable: `{str(report['snapshot_identity']['stable']).lower()}`", + "", "This artifact is the current read-only gate for deciding whether archive", "cardinality numbers can be cited externally without conflating physical", "stored sessions/messages with logical composed sessions.", @@ -800,17 +793,28 @@ def _write_artifacts(out_dir: Path, report: dict[str, Any]) -> None: def build_report(args: LineageValidationArgs) -> dict[str, Any]: config = _config_with_archive_root(get_config(), args.archive_root) index_db = (args.index_db or config.db_path).expanduser().resolve() - conn = open_readonly_connection(index_db) + opened_index_files = open_index_file_set(index_db) + opened_file_set = opened_index_files.__enter__() + opened_main_fd = opened_file_set.main_fd + conn: Connection | None = None observer: Connection | None = None try: - observer = open_readonly_connection(index_db) + conn = open_readonly_connection(index_db, opened_main_fd=opened_main_fd) + opened_file_set.capture_sidecars(index_db) + observer = open_readonly_connection(index_db, opened_main_fd=opened_main_fd) + assert conn is not None observer_data_version_before = _data_version(observer) + opened_file_set.capture_sidecars(index_db) conn.execute("BEGIN") # BEGIN is deferred. Force the first SQLite read before hashing WAL # sidecars so this census's own reader mark cannot make a quiescent # snapshot appear to change between the before and after identities. index_schema_version = _user_version(conn) - snapshot_before = _snapshot_identity(index_db) + snapshot_before = _snapshot_identity( + index_db, + opened_main_fd=opened_main_fd, + opened_sidecar_fds=dict(opened_file_set.sidecar_fds), + ) link_columns = _table_columns(conn, "session_links") missing_link_columns = sorted(REQUIRED_SESSION_LINK_COLUMNS - link_columns) physical_sessions = _count(conn, "sessions") @@ -893,30 +897,34 @@ def build_report(args: LineageValidationArgs) -> dict[str, Any]: f"{topology['quarantined_with_stale_projection_count']} quarantined topology links retain a parent projection" ) if topology["unresolved_read_sample"]["status"] == "not_observed": + effective_count = int(topology["effective_unresolved_count"]) + link_word = "link was" if effective_count == 1 else "links were" reasons.append( - f"{topology['unresolved_count']} unresolved-parent links were not exercised through the reader" + f"{effective_count} effective unresolved-parent {link_word} not exercised through the reader" ) elif topology["unresolved_read_sample"]["status"] == "unsafe": reasons.append("sampled unresolved-parent reads did not remain child-local") - snapshot_after = _snapshot_identity(index_db) + opened_file_set.capture_sidecars(index_db) + snapshot_after = _snapshot_identity( + index_db, + opened_main_fd=opened_main_fd, + opened_sidecar_fds=dict(opened_file_set.sidecar_fds), + ) observer_data_version_after = _data_version(observer) - file_set_stable = snapshot_before["sha256"] == snapshot_after["sha256"] - no_concurrent_commits = observer_data_version_before == observer_data_version_after - snapshot_stable = file_set_stable and no_concurrent_commits - if not file_set_stable: + snapshot_identity = _snapshot_report_identity( + index_db, + snapshot_before, + snapshot_after, + observer_data_version_before=observer_data_version_before, + observer_data_version_after=observer_data_version_after, + ) + if not snapshot_identity["observation_complete"]: + reasons.append("index file-set observation was incomplete") + if not snapshot_identity["file_set_stable"]: reasons.append("index file set changed during the read-only census") - if not no_concurrent_commits: + if not snapshot_identity["no_concurrent_commits"]: reasons.append("index received a concurrent commit during the read-only census") - snapshot_identity = { - "before": snapshot_before, - "after": snapshot_after, - "file_set_stable": file_set_stable, - "observer_data_version_before": observer_data_version_before, - "observer_data_version_after": observer_data_version_after, - "no_concurrent_commits": no_concurrent_commits, - "stable": snapshot_stable, - } report: dict[str, Any] = { "report_version": 2, "captured_at": datetime.now(UTC).isoformat(), @@ -945,10 +953,12 @@ def build_report(args: LineageValidationArgs) -> dict[str, Any]: } report["receipt_sha256"] = _receipt_sha256(report) finally: - conn.rollback() - conn.close() + if conn is not None: + conn.rollback() + conn.close() if observer is not None: observer.close() + opened_index_files.__exit__(None, None, None) if args.out_dir is not None: _write_artifacts(args.out_dir, report) diff --git a/docs/maintenance.md b/docs/maintenance.md index 26141aba65..00dbf9cee3 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -60,17 +60,29 @@ transaction creation before any bookkeeping or candidate generation. For a safe deployment recovery, first choose the exact target package commit. With the daemon stopped, create a fresh verified full-evidence backup. If the preflight reports that a newly introduced durable tier is absent, initialize -only that absent file through the archive ownership gate: +only that absent file through the archive ownership gate. This recovery path is +allowed only for a completely unadopted private archive directory. Any sibling +durable tier, active-index pointer, or durable change-train marker proves that +the archive already has an identity, so the command refuses to create the +missing file and leaves it absent: ```bash polylogue ops maintenance migrate-tier audit --initialize-missing --output-format json ``` -The flag builds the canonical database in memory, writes it directly into an -anonymous inode, and publishes that inode with an atomic no-replace link. It -never exposes a writable staging name, refuses any existing target including -one created concurrently, and never replaces durable data. For each existing -tier that the selected package reports behind, run its numbered +The flag builds the canonical database in memory, writes it into an anonymous +inode, and requires filesystem support for `O_TMPFILE`. If the filesystem does +not support anonymous temporary files, the command fails closed and leaves the +tier absent. It fsyncs the image, publishes it with a no-replace hard link, then +fsyncs the directory. It refuses any existing target including one created +concurrently, and never replaces durable data. + +If publication fails after the file becomes visible, JSON output carries a +`durable_recovery` object. A state of `uncertain` means the command preserved a +visible tier because it could not prove a pathname still names its inode. +Inspect the reported target and remove it manually before retrying. + +For each existing tier that the selected package reports behind, run its numbered migration with the verified full-evidence backup manifest: ```bash diff --git a/docs/plans/degrade-loudly-allowlist.yaml b/docs/plans/degrade-loudly-allowlist.yaml index 05a4c6e498..c2965fa0c8 100644 --- a/docs/plans/degrade-loudly-allowlist.yaml +++ b/docs/plans/degrade-loudly-allowlist.yaml @@ -451,13 +451,6 @@ entries: - Exception occurrence: 0 reason: 'See repair_empty_sessions: same _repair_result(success=False, detail=f"...: {exc}") pattern.' -- path: polylogue/storage/sqlite/archive_tiers/archive.py - function: ._ensure_read_runtime_indexes - exceptions: - - Error - occurrence: 0 - reason: Explicitly documented "best-effort performance-index ensure" (see the function's own docstring) - -- not correctness-affecting. - path: polylogue/storage/sqlite/archive_tiers/archive_plan.py function: ._read_user_version exceptions: diff --git a/polylogue/cli/commands/maintenance/_migrate_tier.py b/polylogue/cli/commands/maintenance/_migrate_tier.py index 87f47a4acf..88eaadb28e 100644 --- a/polylogue/cli/commands/maintenance/_migrate_tier.py +++ b/polylogue/cli/commands/maintenance/_migrate_tier.py @@ -24,6 +24,7 @@ from polylogue.operations.durable_change_train import ( ArchiveOwnershipError, + DurablePublicationError, acquire_durable_archive_ownership, execute_durable_change_train, initialize_missing_durable_tier, @@ -88,7 +89,11 @@ def migrate_tier_command( with acquire_durable_archive_ownership(path.parent, owner_id=f"migrate-tier:{os.getpid()}") as archive_owner: stopped_daemon_evidence_ref = _require_stopped_daemon(path.parent) if initialize_missing: - initialized_version = initialize_missing_durable_tier(path, archive_tier) + initialized_version = initialize_missing_durable_tier( + path, + archive_tier, + directory_fd=archive_owner.directory_fd, + ) initialized = True execution = None else: @@ -111,6 +116,9 @@ def migrate_tier_command( "backup_manifest": str(backup_manifest) if backup_manifest is not None else None, "stopped_daemon_evidence_ref": stopped_daemon_evidence_ref, "error": str(exc), + "durable_recovery": ( + exc.cleanup.as_dict() if isinstance(exc, DurablePublicationError) and exc.cleanup else None + ), }, indent=2, sort_keys=True, @@ -118,6 +126,13 @@ def migrate_tier_command( ) else: click.echo(f"Migration blocked for {tier}: {exc}", err=True) + if isinstance(exc, DurablePublicationError) and exc.cleanup is not None: + cleanup = exc.cleanup + if cleanup.state == "uncertain": + click.echo( + f"Durable recovery required ({cleanup.code}): {cleanup.detail}", + err=True, + ) raise SystemExit(1) from exc result = execution.migration_result if execution is not None else None diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index 7d32dfb2ed..ac744a180c 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -2118,6 +2118,12 @@ async def run_daemon_services( global _daemon_lifecycle, _pidfile_path _process_start.started_at_wall() archive_root_path = Path(archive_root()) + # The ownership proof is descriptor-backed and therefore requires an + # existing root. A daemon is also the production first-run entry point, so + # create an otherwise absent configured root before identity/ownership + # validation rather than making fresh service startup depend on a separate + # bootstrap invocation. + archive_root_path.mkdir(mode=0o700, parents=True, exist_ok=True) from polylogue.storage.archive_identity import assert_writable_archive_identity # Identity precedes schema checks, pidfiles, HTTP startup, and every other diff --git a/polylogue/maintenance/rebuild_index.py b/polylogue/maintenance/rebuild_index.py index 0317ffe239..f66de21ee0 100644 --- a/polylogue/maintenance/rebuild_index.py +++ b/polylogue/maintenance/rebuild_index.py @@ -301,6 +301,23 @@ def _mark_rebuild_transaction_stale_after_provenance_failure( error.add_note(f"could not persist stale rebuild transaction: {checkpoint_error}") +def _retire_empty_source_resume_transaction(root: Path, operation_id: str) -> None: + """Retire a resumable transaction when its source archive is now empty.""" + from polylogue.storage.index_generation import IndexGenerationStore + + store = IndexGenerationStore.for_archive_root(root, repair_anchor=False) + transaction = _reconcile_active_generation_transaction(store, store.load_transaction(operation_id)) + if transaction.status in {"promoted", "promoted-attestation-failed", "stale"}: + raise RuntimeError( + f"rebuild operation {transaction.operation_id} is {transaction.status}; start a new operation" + ) + store.checkpoint_transaction( + transaction, + status="stale", + error="rebuild source is empty; resumable transaction cannot continue", + ) + + def _validate_before_derived_state( provenance: RebuildProvenanceContext, *, @@ -1210,6 +1227,23 @@ def count_source_raw_sessions(root: Path) -> int: return int(row[0]) if row is not None else 0 +def _empty_source_receipt(root: Path, consumed_evidence: dict[str, object]) -> RebuildIndexReceipt: + return RebuildIndexReceipt( + archive_root=str(root), + raw_session_count=0, + selected_raw_count=0, + skipped_by_blob_limit_count=0, + status="empty-source", + materialized=False, + materialization={}, + generation={}, + readiness={}, + replay={}, + operation=_operation_evidence(root, generation=None, transaction=None, recovery_state="empty-source"), + consumed_evidence=consumed_evidence, + ) + + def total_source_blob_bytes(root: Path) -> int: """Total blob payload the rebuild has to replay, for progress and ETA. @@ -1276,10 +1310,11 @@ def filter_raw_ids_by_max_blob_size(root: Path, raw_ids: list[str], max_blob_mb: return [str(row[0]) for row in rows] -def select_rebuild_raw_ids(request: RebuildIndexRequest) -> tuple[int, list[str], int]: +def select_rebuild_raw_ids(request: RebuildIndexRequest, *, raw_count: int | None = None) -> tuple[int, list[str], int]: """Select source rows deterministically before the replay starts.""" root = request.archive_root - raw_count = count_source_raw_sessions(root) + if raw_count is None: + raw_count = count_source_raw_sessions(root) raw_ids = ( list(dict.fromkeys(request.raw_ids)) if request.raw_ids @@ -1322,24 +1357,27 @@ async def rebuild_index_from_source(request: RebuildIndexRequest) -> RebuildInde # validation rather than accidentally reusing another archive/pass. _ACTIVE_EXTERNAL_INVENTORY_TOKEN.set(None) require_rebuild_schema_currency(root) + pre_ownership_raw_count = count_source_raw_sessions(root) + receipt_free_empty_probe = request.operation_id is None and pre_ownership_raw_count == 0 initial_provenance_error: RebuildProvenanceError | None = None - try: - consumed_evidence = _validate_rebuild_provenance_receipt(root, request.schema_inference_receipt_path) - except RebuildProvenanceError as exc: - if request.operation_id is None: - raise - # A resumable operation may need to be retired because this admission - # failed, but that lifecycle mutation must wait until both ownership - # boundaries are held. Control-flow exceptions are intentionally not - # caught here and therefore never change resumability. - initial_provenance_error = exc - consumed_evidence = {} + consumed_evidence: dict[str, object] = {} + if not receipt_free_empty_probe: + try: + consumed_evidence = _validate_rebuild_provenance_receipt(root, request.schema_inference_receipt_path) + except RebuildProvenanceError as exc: + if request.operation_id is None: + raise + # A resumable operation may need to be retired because this admission + # failed, but that lifecycle mutation must wait until both ownership + # boundaries are held. Control-flow exceptions are intentionally not + # caught here and therefore never change resumability. + initial_provenance_error = exc location = ArchiveLocation.resolve(root) # The joined raw-frontier projection is rooted at the co-located active # index. A split-root canary intentionally points that index elsewhere and # validates the selected active generation through its own receipt-bound # route below, so a missing root/index.db must not masquerade as raw debt. - if count_source_raw_sessions(root) and location.active_index_path.parent == root: + if pre_ownership_raw_count and location.active_index_path.parent == root: from polylogue.readiness.capability import raw_frontier_source_selection_block_reason from polylogue.storage.archive_readiness import raw_materialization_readiness_snapshot @@ -1379,23 +1417,32 @@ async def rebuild_index_from_source(request: RebuildIndexRequest) -> RebuildInde try: assert_owns_archive_location(owned, location) require_rebuild_schema_currency(root) - if initial_provenance_error is None: - consumed_evidence = _validate_rebuild_provenance_receipt( - root, - request.schema_inference_receipt_path, - inventory_token=cast( - dict[str, object], consumed_evidence.get("external_ground_truth_inventory_token", {}) - ), - ) + raw_count = count_source_raw_sessions(root) + if initial_provenance_error is not None: + # A resumable request that failed admission must retire its + # transaction before any empty-source shortcut can turn the same + # invalid operation into a successful receipt. + with RebuildLease(root): + assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) + _mark_rebuild_transaction_stale_after_provenance_failure( + root, request.operation_id, initial_provenance_error + ) + raise initial_provenance_error + if raw_count == 0: + if request.operation_id is not None: + with RebuildLease(root): + assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) + _retire_empty_source_resume_transaction(root, request.operation_id) + return _empty_source_receipt(root, consumed_evidence) + consumed_evidence = _validate_rebuild_provenance_receipt( + root, + request.schema_inference_receipt_path, + inventory_token=cast(dict[str, object], consumed_evidence.get("external_ground_truth_inventory_token", {})), + ) # The lease is itself lifecycle state guarded by the provenance gate. # Revalidate again under the lease immediately before the owned body # can create or mutate a candidate/transaction. with RebuildLease(root): - if initial_provenance_error is not None: - _mark_rebuild_transaction_stale_after_provenance_failure( - root, request.operation_id, initial_provenance_error - ) - raise initial_provenance_error try: consumed_evidence = _validate_rebuild_provenance_receipt( root, @@ -1408,7 +1455,11 @@ async def rebuild_index_from_source(request: RebuildIndexRequest) -> RebuildInde _mark_rebuild_transaction_stale_after_provenance_failure(root, request.operation_id, exc) raise return await _rebuild_index_from_source_owned( - request, root=root, owned=owned, consumed_evidence=consumed_evidence + request, + root=root, + owned=owned, + consumed_evidence=consumed_evidence, + raw_count=raw_count, ) finally: owned.release() @@ -1420,6 +1471,7 @@ async def _rebuild_index_from_source_owned( root: Path, owned: OwnedArchiveLocation, consumed_evidence: dict[str, object], + raw_count: int, ) -> RebuildIndexReceipt: """Ownership-proven body of :func:`rebuild_index_from_source`.""" from polylogue.maintenance.archive_verification import ( @@ -1452,22 +1504,8 @@ async def _rebuild_index_from_source_owned( # to preserve the body's indentation and make the outer ownership boundary # explicit at the public entry point. with contextlib.nullcontext(): - raw_count = count_source_raw_sessions(root) if raw_count == 0: - return RebuildIndexReceipt( - archive_root=str(root), - raw_session_count=0, - selected_raw_count=0, - skipped_by_blob_limit_count=0, - status="empty-source", - materialized=False, - materialization={}, - generation={}, - readiness={}, - replay={}, - operation=_operation_evidence(root, generation=None, transaction=None, recovery_state="empty-source"), - consumed_evidence=consumed_evidence, - ) + return _empty_source_receipt(root, consumed_evidence) resumable_full_source = not request.raw_ids and not request.only_missing and request.max_blob_mb is None transaction = None transaction_created_here = False @@ -1569,7 +1607,9 @@ async def _rebuild_index_from_source_owned( ) else: selection_started_at = time.perf_counter() - raw_count, selected_raw_ids, skipped_by_blob_limit_count = select_rebuild_raw_ids(request) + raw_count, selected_raw_ids, skipped_by_blob_limit_count = select_rebuild_raw_ids( + request, raw_count=raw_count + ) selection_elapsed_s = time.perf_counter() - selection_started_at selected_raw_count = len(selected_raw_ids) validate_frozen_source_authority( diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index f2cd93bf61..d85164114b 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -5,8 +5,11 @@ import os import sqlite3 import stat +import sys from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path +from typing import Literal from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier @@ -22,13 +25,44 @@ from polylogue.storage.sqlite.migration_runner import DurableRuntimeConsumerResult, MigrationError +@dataclass(frozen=True, slots=True) +class DurableCleanupOutcome: + """Operator-facing result of recovering a partially visible publication.""" + + state: Literal["not_attempted", "target_absent", "cleaned", "uncertain"] + code: str | None = None + target: str | None = None + detail: str | None = None + + def as_dict(self) -> dict[str, str | None]: + return { + "state": self.state, + "code": self.code, + "target": self.target, + "detail": self.detail, + } + + +class DurablePublicationError(MigrationError): + """Publication failure carrying durable cleanup uncertainty for operators.""" + + def __init__(self, message: str, *, cleanup: DurableCleanupOutcome | None = None) -> None: + super().__init__(message) + self.cleanup = cleanup + + +def _close_publication_descriptor(descriptor: int) -> None: + """Close a publication descriptor through one fault-injectable boundary.""" + os.close(descriptor) + + def acquire_durable_archive_ownership(root: Path, *, owner_id: str) -> OwnedArchiveLocation: """Acquire the stable archive lease shared by daemon and maintenance.""" location = ArchiveLocation.resolve(root) return OwnedArchiveLocation.acquire(location, owner_id=owner_id) -def initialize_missing_durable_tier(path: Path, tier: ArchiveTier) -> int: +def initialize_missing_durable_tier(path: Path, tier: ArchiveTier, *, directory_fd: int | None = None) -> int: """Initialize one absent durable tier while the caller owns the archive. This is deliberately separate from migration. A missing tier has no @@ -37,43 +71,177 @@ def initialize_missing_durable_tier(path: Path, tier: ArchiveTier) -> int: """ from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier + archive_root = path.parent + try: - parent_metadata = path.parent.lstat() + parent_metadata = archive_root.lstat() except FileNotFoundError as exc: - raise MigrationError(f"durable tier parent directory is missing: {path.parent}") from exc + raise MigrationError(f"durable tier parent directory is missing: {archive_root}") from exc + except OSError as exc: + raise MigrationError(f"cannot inspect durable tier parent directory: {archive_root}") from exc + if parent_metadata is None: + raise MigrationError(f"durable tier parent directory is missing: {archive_root}") if ( stat.S_ISLNK(parent_metadata.st_mode) or not stat.S_ISDIR(parent_metadata.st_mode) or parent_metadata.st_uid != os.geteuid() or stat.S_IMODE(parent_metadata.st_mode) & 0o022 ): - raise MigrationError(f"durable tier parent is not a private owned directory: {path.parent}") + raise MigrationError(f"durable tier parent is not a private owned directory: {archive_root}") + directory_descriptor: int | None = None try: - path.lstat() - except FileNotFoundError: - pass - else: - raise MigrationError(f"{tier.value} tier already exists; refusing missing-tier initialization: {path}") - - # Build the canonical database in memory, copy its serialized image into - # an anonymous inode, then publish that exact inode with link(2). No named - # staging path exists for a concurrent same-UID process to replace or - # mutate, and link cannot replace a target that appears concurrently. - anonymous_flag = getattr(os, "O_TMPFILE", 0) - if not anonymous_flag: - raise MigrationError("missing-tier initialization requires anonymous-file publication support") - publication_descriptor: int | None = None + if directory_fd is not None: + directory_descriptor = os.dup(directory_fd) + else: + directory_descriptor = os.open( + archive_root, + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + except OSError as exc: + raise MigrationError( + f"cannot publish {tier.value} tier at {path}: cannot anchor durable tier parent directory" + ) from exc + try: - try: - publication_descriptor = os.open( - path.parent, - os.O_RDWR | anonymous_flag | getattr(os, "O_CLOEXEC", 0), - 0o600, + assert directory_descriptor is not None + anchored_metadata = os.fstat(directory_descriptor) + if (anchored_metadata.st_dev, anchored_metadata.st_ino) != (parent_metadata.st_dev, parent_metadata.st_ino): + raise MigrationError(f"durable tier parent directory changed during validation: {archive_root}") + + def adoption_lstat(relative: str, description: str) -> os.stat_result | None: + try: + return os.stat(relative, dir_fd=directory_descriptor, follow_symlinks=False) + except FileNotFoundError: + return None + except OSError as exc: + raise MigrationError(f"cannot inspect {description}: {archive_root / relative}") from exc + + def directory_entries(relative: str, metadata: os.stat_result, description: str) -> list[str]: + try: + child_descriptor = os.open( + relative, + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=directory_descriptor, + ) + except OSError as exc: + raise MigrationError(f"cannot inspect {description}: {archive_root / relative}") from exc + try: + child_metadata = os.fstat(child_descriptor) + if (child_metadata.st_dev, child_metadata.st_ino) != (metadata.st_dev, metadata.st_ino): + raise MigrationError( + f"archive directory entry changed during validation: {archive_root / relative}" + ) + return os.listdir(child_descriptor) + except OSError as exc: + raise MigrationError(f"cannot inspect {description}: {archive_root / relative}") from exc + finally: + os.close(child_descriptor) + + target_name = path.name + + def assert_no_adoption_evidence(*, check_target: bool = True) -> None: + if check_target and adoption_lstat(target_name, "durable tier target") is not None: + raise MigrationError(f"{tier.value} tier already exists; refusing missing-tier initialization: {path}") + + durable_siblings = tuple(f"{sibling.value}.db" for sibling in ArchiveTier if sibling is not tier) + existing_siblings = [ + archive_root / sibling + for sibling in durable_siblings + if adoption_lstat(sibling, "archive tier") is not None + ] + adoption_markers: list[Path] = [] + active_pointer_marker = ".index-active-pointer" + if adoption_lstat(active_pointer_marker, "active index pointer") is not None: + # Presence is enough. ArchiveLocation intentionally ignores a dangling + # symlink via Path.exists(), but missing-tier initialization must treat + # malformed or dangling adoption evidence as established and fail + # closed rather than publishing an empty durable database. + adoption_markers.append(archive_root / active_pointer_marker) + blob_relative = "blob" + blob_metadata = adoption_lstat(blob_relative, "retained blob path") + if blob_metadata is not None: + if stat.S_ISLNK(blob_metadata.st_mode) or not stat.S_ISDIR(blob_metadata.st_mode): + adoption_markers.append(archive_root / blob_relative) + else: + blob_has_entries = bool(directory_entries(blob_relative, blob_metadata, "retained blob path")) + if blob_has_entries: + adoption_markers.append(archive_root / blob_relative) + maintenance_state_relative = ".maintenance-state" + maintenance_state_metadata = adoption_lstat(maintenance_state_relative, "maintenance state parent") + if maintenance_state_metadata is not None: + if stat.S_ISLNK(maintenance_state_metadata.st_mode) or not stat.S_ISDIR( + maintenance_state_metadata.st_mode + ): + adoption_markers.append(archive_root / maintenance_state_relative) + else: + known_maintenance_children = { + "durable-change-trains", + "source-continuity-pending", + "source-continuity-refreshes", + } + for name in directory_entries( + maintenance_state_relative, maintenance_state_metadata, "maintenance state parent" + ): + if name not in known_maintenance_children: + adoption_markers.append(archive_root / maintenance_state_relative / name) + train_marker_relative = ".maintenance-state/durable-change-trains" + train_marker_metadata = adoption_lstat(train_marker_relative, "durable change-train adoption marker") + if train_marker_metadata is not None: + if stat.S_ISLNK(train_marker_metadata.st_mode) or not stat.S_ISDIR(train_marker_metadata.st_mode): + adoption_markers.append(archive_root / train_marker_relative) + else: + marker_entries = tuple( + directory_entries( + train_marker_relative, train_marker_metadata, "durable change-train adoption marker" + ) + ) + if marker_entries: + for marker_name in (".bootstrap", ".bootstrap.pending"): + marker_relative = f"{train_marker_relative}/{marker_name}" + if adoption_lstat(marker_relative, "durable bootstrap marker") is not None: + adoption_markers.append(archive_root / marker_relative) + train_marker_path = archive_root / train_marker_relative + if train_marker_path not in adoption_markers: + adoption_markers.append(train_marker_path) + retained_evidence_roots = ( + (".index-generations", "retained index-generation evidence"), + (".index-rebuild-transactions", "retained index-rebuild transaction evidence"), + (".maintenance-state/source-continuity-pending", "source-continuity recovery evidence"), + (".maintenance-state/source-continuity-refreshes", "source-continuity refresh evidence"), ) - except OSError as exc: - raise MigrationError(f"cannot create anonymous durable-tier publication inode: {path.parent}") from exc + for evidence_relative, description in retained_evidence_roots: + evidence_metadata = adoption_lstat(evidence_relative, description) + if evidence_metadata is None: + continue + if stat.S_ISLNK(evidence_metadata.st_mode) or not stat.S_ISDIR(evidence_metadata.st_mode): + adoption_markers.append(archive_root / evidence_relative) + continue + has_retained_evidence = bool(directory_entries(evidence_relative, evidence_metadata, description)) + if has_retained_evidence: + adoption_markers.append(archive_root / evidence_relative) + if existing_siblings or adoption_markers: + details = ", ".join(str(item) for item in (*existing_siblings, *adoption_markers)) + raise MigrationError( + f"cannot initialize missing {tier.value} tier in an established archive; " + f"adoption marker(s): {details}" + ) + assert_no_adoption_evidence() + except BaseException: + os.close(directory_descriptor) + raise + + # Build the canonical database in memory before choosing the publication + # substrate. Both publication paths link one exact serialized image and + # never replace a target that appears concurrently. + try: memory_database = sqlite3.connect(":memory:") try: initialize_archive_tier(memory_database, tier) @@ -83,47 +251,164 @@ def initialize_missing_durable_tier(path: Path, tier: ArchiveTier) -> int: if not initialized_image: raise MigrationError(f"canonical {tier.value} tier initialization produced an empty database image") + anonymous_flag = getattr(os, "O_TMPFILE", 0) + if not anonymous_flag: + raise MigrationError( + f"cannot initialize missing {tier.value} tier: filesystem does not support O_TMPFILE: {path}" + ) + except BaseException: + os.close(directory_descriptor) + raise + publication_descriptor: int | None = None + publication_identity: tuple[int, int] | None = None + published_target = False + + def cleanup_published_target(primary: BaseException) -> DurableCleanupOutcome: + """Assess a published target without mutating an uncertain pathname. + + POSIX pathname operations do not provide a portable conditional unlink + or rename keyed by ``(st_dev, st_ino)``. A checked ``rename`` can still + move a foreign inode after the final identity check, and restoring it + can overwrite a newer target. Preserve the target and surface the + uncertainty unless it is already absent or has visibly changed. + """ + if not published_target or publication_identity is None: + return DurableCleanupOutcome("not_attempted") + try: + published_metadata = adoption_lstat(target_name, "published durable tier") + if published_metadata is None: + return DurableCleanupOutcome("target_absent", target=str(path)) + except FileNotFoundError: + return DurableCleanupOutcome("target_absent", target=str(path)) + except MigrationError as exc: + detail = f"could not inspect published durable tier during recovery: {path}: {exc}" + primary.add_note(detail) + return DurableCleanupOutcome("uncertain", "leaf_inspection_failed", str(path), detail) + except OSError as exc: + detail = f"could not inspect published durable tier during recovery: {path}: {exc}" + primary.add_note(detail) + return DurableCleanupOutcome("uncertain", "leaf_inspection_failed", str(path), detail) + if (published_metadata.st_dev, published_metadata.st_ino) != publication_identity: + detail = f"published durable tier changed before recovery; preserving foreign target: {path}" + primary.add_note(detail) + return DurableCleanupOutcome("uncertain", "leaf_replaced", str(path), detail) + detail = ( + f"published durable tier remains after publication failure; cleanup deferred because no conditional " + f"inode removal is available: {path}" + ) + primary.add_note(detail) + return DurableCleanupOutcome("uncertain", "cleanup_not_atomic", str(path), detail) + + try: + try: + publication_descriptor = os.open( + ".", + os.O_RDWR | anonymous_flag | getattr(os, "O_CLOEXEC", 0), + 0o600, + dir_fd=directory_descriptor, + ) + except OSError as exc: + raise MigrationError( + f"cannot initialize missing {tier.value} tier: anonymous durable publication failed: {path}" + ) from exc + + assert publication_descriptor is not None + descriptor = publication_descriptor source_offset = 0 while source_offset < len(initialized_image): written_offset = 0 chunk = initialized_image[source_offset : source_offset + 1024 * 1024] while written_offset < len(chunk): - written = os.write(publication_descriptor, chunk[written_offset:]) + written = os.write(descriptor, chunk[written_offset:]) if written <= 0: raise MigrationError("durable-tier publication copy made no progress") written_offset += written source_offset += len(chunk) - os.fsync(publication_descriptor) - publication_metadata = os.fstat(publication_descriptor) + os.fsync(descriptor) + publication_metadata = os.fstat(descriptor) if ( not stat.S_ISREG(publication_metadata.st_mode) or publication_metadata.st_nlink != 0 or publication_metadata.st_size != len(initialized_image) ): - raise MigrationError(f"anonymous durable-tier publication image is incomplete: {path}") + raise MigrationError(f"durable-tier publication image is incomplete: {path}") publication_identity = (publication_metadata.st_dev, publication_metadata.st_ino) + # Image construction can take long enough for retained archive evidence + # to appear. Re-census immediately before the first visible link so an + # empty durable tier is never adopted over a newly established archive. + # ``link`` is the atomic no-replacement check for the target itself; + # re-census only evidence whose appearance would otherwise make this + # empty tier an unsafe adoption. + assert_no_adoption_evidence(check_target=False) try: - # O_TMPFILE plus link(2) publishes one descriptor-backed inode - # without resolving the replaceable named staging path again. - os.link(f"/proc/self/fd/{publication_descriptor}", path, follow_symlinks=True) + os.link( + f"/proc/self/fd/{descriptor}", + target_name, + dst_dir_fd=directory_descriptor, + follow_symlinks=True, + ) except FileExistsError as exc: raise MigrationError( f"{tier.value} tier appeared during initialization; refusing to replace it: {path}" ) from exc - published_metadata = path.lstat() + except OSError as exc: + raise MigrationError( + f"cannot initialize missing {tier.value} tier: anonymous durable publication failed: {path}" + ) from exc + published_target = True + published_metadata = adoption_lstat(target_name, "published durable tier") + if published_metadata is None: + raise MigrationError(f"published durable tier disappeared before identity validation: {path}") if ( not stat.S_ISREG(published_metadata.st_mode) or (published_metadata.st_dev, published_metadata.st_ino) != publication_identity ): raise MigrationError(f"published durable tier identity does not match the staged database: {path}") - directory_descriptor = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) - try: - os.fsync(directory_descriptor) - finally: - os.close(directory_descriptor) + os.fsync(directory_descriptor) + except MigrationError as exc: + cleanup = cleanup_published_target(exc) + if published_target: + raise DurablePublicationError( + f"cannot publish {tier.value} tier at {path} via durable publication", cleanup=cleanup + ) from exc + raise + except OSError as exc: + cleanup = cleanup_published_target(exc) + raise DurablePublicationError( + f"cannot publish {tier.value} tier at {path} via durable publication", cleanup=cleanup + ) from exc finally: + primary_exception = sys.exception() + close_failures: list[tuple[BaseException, OSError]] = [] if publication_descriptor is not None: - os.close(publication_descriptor) + try: + _close_publication_descriptor(publication_descriptor) + except OSError as exc: + cleanup = cleanup_published_target(exc) + if cleanup.state == "uncertain": + exc.add_note(cleanup.detail or cleanup.code or "durable cleanup is uncertain") + failure: BaseException + if published_target: + failure = DurablePublicationError( + f"cannot close {tier.value} tier publication at {path} after durable publication", + cleanup=cleanup, + ) + else: + failure = MigrationError( + f"cannot close {tier.value} tier publication at {path} after durable publication" + ) + close_failures.append((failure, exc)) + try: + _close_publication_descriptor(directory_descriptor) + except OSError as exc: + failure = MigrationError(f"cannot close durable tier parent directory: {archive_root}") + close_failures.append((failure, exc)) + if primary_exception is not None: + for failure, _cause in close_failures: + primary_exception.add_note(str(failure)) + elif close_failures: + failure, cause = close_failures[0] + raise failure from cause from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER diff --git a/polylogue/storage/archive_identity.py b/polylogue/storage/archive_identity.py index af260de5a7..7b6745b068 100644 --- a/polylogue/storage/archive_identity.py +++ b/polylogue/storage/archive_identity.py @@ -6,6 +6,7 @@ import logging import os import socket +import stat import sys import threading import time @@ -20,7 +21,7 @@ logger = logging.getLogger(__name__) _LOCAL_ARCHIVE_OWNERS_LOCK = threading.RLock() -_LOCAL_ARCHIVE_OWNERS: dict[Path, tuple[int, int, str]] = {} +_LOCAL_ARCHIVE_OWNERS: dict[tuple[int, int], tuple[int, int, int, str]] = {} ArchiveTierName = Literal["source", "index", "embeddings", "user", "ops", "audit"] TIER_FILENAMES: tuple[tuple[ArchiveTierName, str], ...] = ( @@ -110,8 +111,21 @@ def resolve(cls, root: Path) -> ArchiveLocation: configured_index = next(tier for tier in configured if tier.name == "index") pointer_file = configured_root / ".index-active-pointer" pointer: Path | None = None - if pointer_file.exists(): - raw = pointer_file.read_text(encoding="utf-8").strip() + try: + pointer_metadata = pointer_file.lstat() + except FileNotFoundError: + pointer_metadata = None + except OSError as exc: + raise ArchiveLocationError(f"cannot inspect active index pointer: {pointer_file}") from exc + if pointer_metadata is not None: + try: + raw = ( + os.readlink(pointer_file) + if stat.S_ISLNK(pointer_metadata.st_mode) + else pointer_file.read_text(encoding="utf-8") + ).strip() + except (OSError, ValueError) as exc: + raise ArchiveLocationError(f"cannot read active index pointer: {pointer_file}") from exc candidate = Path(raw) if not candidate.is_absolute() or candidate.name != "index.db": raise ArchiveLocationError(f"invalid active index pointer: {candidate}") @@ -336,11 +350,20 @@ class OwnedArchiveLocation: for that generation. """ - def __init__(self, location: ArchiveLocation) -> None: + def __init__(self, location: ArchiveLocation, *, root_fd: int, root_identity: tuple[int, int]) -> None: self.location = location self.lock_path = location.configured_root / ".archive-ownership.lock" self.owner_id: str | None = None self._fd: int | None = None + self._root_fd = root_fd + self.root_identity = root_identity + + @property + def directory_fd(self) -> int: + """Return the descriptor that pins the owned archive-root directory.""" + if self._root_fd < 0: + raise ArchiveOwnershipError("archive ownership root descriptor is closed") + return self._root_fd @classmethod def acquire( @@ -359,33 +382,57 @@ def acquire( blocker, matching ``index_generation``'s stale-lease handling. """ owner = owner_id or f"pid={os.getpid()} host={socket.gethostname()} token={uuid.uuid4().hex}" - instance = cls(location) + root_fd = _open_archive_root_fd(location.configured_root) + root_metadata = os.fstat(root_fd) + instance = cls( + location, + root_fd=root_fd, + root_identity=(root_metadata.st_dev, root_metadata.st_ino), + ) + root_key = instance.root_identity with _LOCAL_ARCHIVE_OWNERS_LOCK: - existing = _LOCAL_ARCHIVE_OWNERS.get(instance.lock_path) - if existing is None or not allow_reentrant: - fd = _acquire_ownership_lock_fd(instance.lock_path, owner=owner) - _LOCAL_ARCHIVE_OWNERS[instance.lock_path] = (fd, 1, owner) - instance.owner_id = owner - else: - fd, references, existing_owner = existing - _LOCAL_ARCHIVE_OWNERS[instance.lock_path] = (fd, references + 1, existing_owner) - instance.owner_id = existing_owner - instance._fd = fd + try: + existing = _LOCAL_ARCHIVE_OWNERS.get(root_key) + if existing is None or not allow_reentrant: + fd = _acquire_ownership_lock_fd(instance.lock_path, owner=owner, dir_fd=root_fd) + _LOCAL_ARCHIVE_OWNERS[root_key] = (fd, root_fd, 1, owner) + instance.owner_id = owner + else: + fd, existing_root_fd, references, existing_owner = existing + os.close(root_fd) + instance._root_fd = existing_root_fd + _LOCAL_ARCHIVE_OWNERS[root_key] = (fd, existing_root_fd, references + 1, existing_owner) + instance.owner_id = existing_owner + instance._fd = fd + _assert_archive_root_identity( + instance.location.configured_root, + instance.directory_fd, + instance.root_identity, + ) + except BaseException: + if instance._fd is not None: + instance.release() + elif instance._root_fd >= 0: + os.close(instance._root_fd) + instance._root_fd = -1 + raise return instance def release(self) -> None: if self._fd is not None: with _LOCAL_ARCHIVE_OWNERS_LOCK: - existing = _LOCAL_ARCHIVE_OWNERS.get(self.lock_path) + existing = _LOCAL_ARCHIVE_OWNERS.get(self.root_identity) if existing is not None and existing[0] == self._fd: - fd, references, owner = existing + fd, root_fd, references, owner = existing if references <= 1: - _LOCAL_ARCHIVE_OWNERS.pop(self.lock_path, None) + _LOCAL_ARCHIVE_OWNERS.pop(self.root_identity, None) fcntl.flock(fd, fcntl.LOCK_UN) os.close(fd) + os.close(root_fd) else: - _LOCAL_ARCHIVE_OWNERS[self.lock_path] = (fd, references - 1, owner) + _LOCAL_ARCHIVE_OWNERS[self.root_identity] = (fd, root_fd, references - 1, owner) self._fd = None + self._root_fd = -1 def __enter__(self) -> OwnedArchiveLocation: return self @@ -415,6 +462,7 @@ def assert_owns_archive_location(owned: OwnedArchiveLocation, location: ArchiveL "archive ownership proof does not cover this location: " f"owned={owned.location.configured_root} target={location.configured_root}" ) + _assert_archive_root_identity(owned.location.configured_root, owned.directory_fd, owned.root_identity) if not owned.location.active_index.same_file(location.active_index): raise ArchiveOwnershipError( "archive ownership proof is stale for the current active generation: " @@ -423,11 +471,11 @@ def assert_owns_archive_location(owned: OwnedArchiveLocation, location: ArchiveL ) -def _lock_holder_pid(path: Path) -> int | None: +def _lock_holder_pid(path: Path, *, fd: int | None = None) -> int | None: """Best-effort recorded pid from an existing lock file; ``None`` if absent/unreadable.""" try: - text = path.read_text(encoding="utf-8") - except OSError: + text = path.read_text(encoding="utf-8") if fd is None else os.pread(fd, 4096, 0).decode("utf-8") + except (OSError, ValueError): return None for token in text.split(): if token.startswith("pid="): @@ -456,7 +504,37 @@ def _pid_is_alive(pid: int) -> bool: _STALE_RECLAIM_RETRY_SECONDS = 0.01 -def _acquire_ownership_lock_fd(path: Path, *, owner: str) -> int: +def _open_archive_root_fd(path: Path) -> int: + """Open one archive-root directory and reject a path swap around the open.""" + try: + fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_CLOEXEC", 0)) + except OSError as exc: + raise ArchiveOwnershipError(f"cannot open archive root directory: {path}") from exc + try: + _assert_archive_root_identity(path, fd, None) + except BaseException: + os.close(fd) + raise + return fd + + +def _assert_archive_root_identity(path: Path, fd: int, expected: tuple[int, int] | None) -> None: + """Prove the configured root still names the directory held by ``fd``.""" + metadata = os.fstat(fd) + if not stat.S_ISDIR(metadata.st_mode): + raise ArchiveOwnershipError(f"archive root is not a directory: {path}") + identity = (metadata.st_dev, metadata.st_ino) + if expected is not None and identity != expected: + raise ArchiveOwnershipError(f"archive root descriptor changed: {path}") + try: + path_metadata = path.stat() + except OSError as exc: + raise ArchiveOwnershipError(f"archive root disappeared during ownership validation: {path}") from exc + if (path_metadata.st_dev, path_metadata.st_ino) != identity: + raise ArchiveOwnershipError(f"archive root changed during ownership validation: {path}") + + +def _acquire_ownership_lock_fd(path: Path, *, owner: str, dir_fd: int | None = None) -> int: """Open ``path`` and take an exclusive, non-blocking ``flock`` proving ownership. Never opens a sqlite3 connection -- callers rely on this to fail before @@ -477,12 +555,38 @@ def _acquire_ownership_lock_fd(path: Path, *, owner: str) -> int: confirm it's dead -- so retrying the identical ``flock`` call converges quickly without ever creating a second inode to race against. """ - path.parent.mkdir(parents=True, exist_ok=True) - fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + lock_flags = ( + os.O_RDWR + | os.O_CREAT + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + if dir_fd is None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, lock_flags, 0o600) + except OSError as exc: + raise ArchiveOwnershipError(f"cannot open archive ownership lock: {path}") from exc + else: + try: + fd = os.open( + path.name, + lock_flags, + 0o600, + dir_fd=dir_fd, + ) + except OSError as exc: + raise ArchiveOwnershipError(f"cannot open archive ownership lock: {path}") from exc + try: + _validate_ownership_lock_fd(path, fd, dir_fd=dir_fd) + except BaseException: + os.close(fd) + raise try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError as exc: - holder_pid = _lock_holder_pid(path) + holder_pid = _lock_holder_pid(path, fd=fd) if holder_pid is None or _pid_is_alive(holder_pid): os.close(fd) suffix = f" (pid={holder_pid})" if holder_pid is not None else "" @@ -501,7 +605,37 @@ def _acquire_ownership_lock_fd(path: Path, *, owner: str) -> int: else: os.close(fd) raise ArchiveOwnershipError(f"archive location already owned: {path}") from exc - os.ftruncate(fd, 0) - os.write(fd, owner.encode("utf-8")) - os.fsync(fd) + try: + _validate_ownership_lock_fd(path, fd, dir_fd=dir_fd) + except BaseException: + os.close(fd) + raise + try: + os.ftruncate(fd, 0) + os.write(fd, owner.encode("utf-8")) + os.fsync(fd) + except OSError as exc: + os.close(fd) + raise ArchiveOwnershipError(f"cannot record archive ownership lock owner: {path}") from exc return fd + + +def _validate_ownership_lock_fd(path: Path, fd: int, *, dir_fd: int | None) -> None: + """Require the canonical lock entry to name the locked private inode.""" + try: + metadata = os.fstat(fd) + except OSError as exc: + raise ArchiveOwnershipError(f"cannot inspect archive ownership lock: {path}") from exc + if not stat.S_ISREG(metadata.st_mode): + raise ArchiveOwnershipError(f"archive ownership lock is not a regular file: {path}") + if metadata.st_nlink != 1: + raise ArchiveOwnershipError(f"archive ownership lock has unexpected link count: {path}") + try: + if dir_fd is None: + path_metadata = os.stat(path, follow_symlinks=False) + else: + path_metadata = os.stat(path.name, dir_fd=dir_fd, follow_symlinks=False) + except OSError as exc: + raise ArchiveOwnershipError(f"cannot inspect archive ownership lock pathname: {path}") from exc + if (path_metadata.st_dev, path_metadata.st_ino) != (metadata.st_dev, metadata.st_ino): + raise ArchiveOwnershipError(f"archive ownership lock pathname changed during validation: {path}") diff --git a/polylogue/storage/sqlite/archive_tiers/archive.py b/polylogue/storage/sqlite/archive_tiers/archive.py index 0c1c1a2757..2006366cf4 100644 --- a/polylogue/storage/sqlite/archive_tiers/archive.py +++ b/polylogue/storage/sqlite/archive_tiers/archive.py @@ -1650,6 +1650,10 @@ class InactiveCandidateDurableWriteError(RuntimeError): """An inactive generation attempted to mutate read-through durable state.""" +class ReadOnlyArchiveError(RuntimeError): + """A read-only archive evidence store received a mutation request.""" + + class _InactiveCandidateBlobPublisher(ArchiveBlobPublisher): """Read frozen blob bytes while refusing candidate publication attempts.""" @@ -1748,17 +1752,22 @@ def __init__( source_tier_acquisition: bool = False, frozen_source_validation: bool = False, frozen_index_path: Path | None = None, + opened_index_fd: int | None = None, ) -> None: if source_tier_acquisition and read_only: raise ValueError("source_tier_acquisition mode is a writer mode; read_only must be False") if frozen_source_validation and (not read_only or owned_inactive_generation is not None): raise ValueError("frozen source validation requires a read-only active archive") - if frozen_index_path is not None and not frozen_source_validation: - raise ValueError("a frozen index path is valid only for frozen source validation") + if frozen_index_path is not None and not read_only: + raise ValueError("a pinned index path is valid only for read-only archive access") + if opened_index_fd is not None and not read_only: + raise ValueError("an opened index descriptor is valid only for read-only archive access") self._source_tier_acquisition = source_tier_acquisition self._owned_inactive_generation = owned_inactive_generation self._frozen_source_validation = frozen_source_validation self._frozen_index_path = frozen_index_path + self._opened_index_fd = opened_index_fd + self._pinned_read = frozen_index_path is not None self._inactive_candidate_durable_read_only = owned_inactive_generation is not None or frozen_source_validation self._active_writer_lease = None if not read_only: @@ -1828,6 +1837,7 @@ def __init__( initialize=initialize and not source_tier_acquisition and owned_inactive_generation is None, read_only=read_only, read_timeout=read_timeout, + opened_index_fd=opened_index_fd, # polylogue-623q: only ever True for a write connection against # an OWNED INACTIVE generation -- never read until promoted, # discarded wholesale on any failure -- so it is safe to open @@ -1853,6 +1863,7 @@ def _initialize_store( read_only: bool, read_timeout: float, bulk_build_profile: bool = False, + opened_index_fd: int | None = None, ) -> None: self.archive_root = archive_root self.source_db_path = archive_root / "source.db" @@ -1861,6 +1872,8 @@ def _initialize_store( self.user_db_path = archive_root / "user.db" self.ops_db_path = archive_root / "ops.db" self._read_only = read_only + if opened_index_fd is not None and not read_only: + raise ValueError("an opened index descriptor is valid only for read-only archive access") # Attribute type declarations shared by every open mode (the # source-tier acquisition branch below returns early, so inference # from a single assignment site would otherwise mistype these). @@ -1908,10 +1921,12 @@ def _initialize_store( return if initialize: initialize_active_archive_root(archive_root) - if read_only and not self._frozen_source_validation: - self._ensure_read_runtime_indexes() if read_only: - self._conn = sqlite3.connect(f"file:{self.index_db_path}?mode=ro", uri=True, timeout=read_timeout) + self._conn = open_readonly_connection( + self.index_db_path, + timeout=read_timeout, + opened_main_fd=opened_index_fd, + ) pragma_statements = READ_CONNECTION_PRAGMA_STATEMENTS else: self._conn = ( @@ -1929,11 +1944,10 @@ def _initialize_store( self._conn.execute(statement) if read_only: self._conn.execute(f"PRAGMA busy_timeout = {max(0, int(read_timeout * 1000))}") - else: + elif not self._pinned_read: # Fresh-bootstrap and same-version reopen both skip runtime-index # ensure elsewhere (initialize_archive_tier only replays DDL once, - # at current_version==0; the read-only path has its own ensure in - # _ensure_read_runtime_indexes). Owned inactive generations (bulk + # at current_version==0. Owned inactive generations (bulk # rebuilds, revision backfill) open exactly this write connection # and nothing else, so without this call a generation could run # its whole lifetime — including prefix-tail dependent rewrites — @@ -1950,16 +1964,41 @@ def _initialize_store( self._blob_publisher = publisher_type(self.source_db_path, self.archive_root / "blob") self._attach_user_tier_if_present() + def _require_writable(self, operation: str) -> None: + """Reject mutations before they can open or use a writable tier.""" + if self._read_only: + raise ReadOnlyArchiveError(f"read-only archive evidence cannot {operation}") + @classmethod - def open_existing(cls, archive_root: Path, *, read_only: bool = True, read_timeout: float = 5.0) -> ArchiveStore: + def open_existing( + cls, + archive_root: Path, + *, + read_only: bool = True, + read_timeout: float = 5.0, + index_path: Path | None = None, + opened_main_fd: int | None = None, + ) -> ArchiveStore: """Open archive tier files. Read-only opens never bootstrap missing tiers; read/status surfaces must not create an empty archive and then report it as usable. Writers opt - into bootstrap by passing ``read_only=False``. + into bootstrap by passing ``read_only=False``. Read-only evidence tools + may pass an already-resolved ``index_path`` to remain pinned to one + physical generation across an active-pointer promotion. An opened main + descriptor can additionally bind the index connection to that inode. """ + if index_path is not None and not read_only: + raise ValueError("index_path is valid only for read-only archive access") initialize = not read_only - return cls(archive_root, initialize=initialize, read_only=read_only, read_timeout=read_timeout) + return cls( + archive_root, + initialize=initialize, + read_only=read_only, + read_timeout=read_timeout, + frozen_index_path=index_path, + opened_index_fd=opened_main_fd, + ) @classmethod def open_source_tier_acquisition(cls, archive_root: Path) -> ArchiveStore: @@ -2007,22 +2046,6 @@ def _needs_tier_bootstrap(archive_root: Path) -> bool: for filename in ("source.db", "index.db", "embeddings.db", "user.db", "ops.db") ) - def _ensure_read_runtime_indexes(self) -> None: - """Best-effort performance-index ensure before opening the read connection.""" - if not self.index_db_path.exists(): - return - try: - with closing(sqlite3.connect(self.index_db_path)) as conn: - current_version = int(conn.execute("PRAGMA user_version").fetchone()[0]) - if current_version != archive_tier_spec(ArchiveTier.INDEX).version: - return - for statement in WRITE_CONNECTION_PRAGMA_STATEMENTS: - conn.execute(statement) - ensure_runtime_indexes_sync(conn) - conn.commit() - except sqlite3.Error: - return - def set_read_progress_guard(self, guard: Callable[[], int], *, n_opcodes: int = 2000) -> None: """Install a SQLite progress handler on the index read connection. @@ -2059,9 +2082,9 @@ def interrupt_reads(self) -> None: self._conn.interrupt() def _ensure_source_conn(self) -> sqlite3.Connection: - """Return the persistent source.db write connection, opening it lazily.""" + """Return the persistent source.db connection, opening it lazily.""" if self._source_conn is None: - if self._inactive_candidate_durable_read_only: + if self._read_only or self._inactive_candidate_durable_read_only: conn = sqlite3.connect(f"file:{self.source_db_path}?mode=ro", uri=True) conn.execute("PRAGMA query_only = ON") else: @@ -2072,6 +2095,7 @@ def _ensure_source_conn(self) -> sqlite3.Connection: def _open_user_write_connection(self, *, initialize: bool = False) -> sqlite3.Connection: """Open user.db for mutation unless this store is an inactive candidate.""" + self._require_writable("mutate user.db") if self._inactive_candidate_durable_read_only: raise InactiveCandidateDurableWriteError( "inactive candidate generations may read frozen user assertions but may not mutate user.db" @@ -2086,6 +2110,7 @@ def commit(self) -> None: Raw ingest writes commit source references promptly to consume publication receipts; bulk cadence applies to the derived index. """ + self._require_writable("commit archive writes") self._conn.commit() self._consume_index_blob_receipts() self._flush_pending_raw_parse_states() @@ -2117,6 +2142,7 @@ def close(self) -> None: def write_parsed(self, session: ParsedSession, *, content_hash: str | None = None) -> str: """Write a parsed session to index.db.""" + self._require_writable("write index.db") acquired, refs = self._preacquire_attachment_blobs( session, source_path=f"session:{session.provider_session_id}", @@ -2286,6 +2312,7 @@ def write_raw_and_parsed( blob_publication_receipt_id: str | None = None, finalize_raw_parse: bool = True, ) -> tuple[str, str]: + self._require_writable("write source.db and index.db") return write_raw_and_parsed( self, session, @@ -2316,6 +2343,7 @@ def write_raw_payload( revision: RawRevisionEnvelope | None = None, post_parse: bool = False, ) -> str: + self._require_writable("write source.db raw evidence") return write_raw_payload( self, provider=provider, @@ -2351,6 +2379,7 @@ def write_hook_event( but NO ``raw_sessions`` row, so a hook can never materialize into a standalone empty session (polylogue-31r1). """ + self._require_writable("write source.db hook evidence") if self._blob_publisher is None: raise RuntimeError("raw archive writes require a writable archive publisher") raw_hash, _raw_size = self._blob_publisher.write_from_bytes(payload) @@ -2378,6 +2407,7 @@ def write_hook_event( def delete_hook_event(self, hook_event_id: str) -> bool: """Delete a hook event and its source-tier payload reference.""" + self._require_writable("delete source.db hook evidence") return delete_source_hook_event(self._ensure_source_conn(), hook_event_id) def write_raw_blob_ref( @@ -2395,6 +2425,7 @@ def write_raw_blob_ref( revision: RawRevisionEnvelope | None = None, post_parse: bool = False, ) -> str: + self._require_writable("write source.db blob reference") return write_raw_blob_ref( self, provider=provider, @@ -2426,6 +2457,7 @@ def admit_raw_artifact_payload( See :func:`polylogue.storage.sqlite.archive_tiers.revision_governance.admit_raw_artifact_payload`. """ + self._require_writable("admit source.db artifact") return admit_raw_artifact_payload( self, provider=provider, @@ -2452,6 +2484,7 @@ def admit_raw_artifact_blob_ref( blob_publication_receipt_id: str | None = None, ) -> RawAdmissionResult: """Route a prepublished non-conversational blob through typed admission.""" + self._require_writable("admit source.db artifact blob reference") return admit_raw_artifact_blob_ref( self, provider=provider, @@ -2479,6 +2512,7 @@ def write_parsed_for_retained_raw( finalize_raw_parse: bool = True, revision_authoritative: bool = False, ) -> tuple[str, str]: + self._require_writable("write retained source.db and index.db evidence") return write_parsed_for_retained_raw( self, session, @@ -2507,6 +2541,7 @@ def write_parsed_for_retained_raw_result( finalize_raw_parse: bool = True, revision_authoritative: bool = False, ) -> ArchiveRawParsedWriteResult: + self._require_writable("write retained source.db and index.db evidence") return write_parsed_for_retained_raw_result( self, session, @@ -2522,9 +2557,11 @@ def write_parsed_for_retained_raw_result( ) def bind_raw_revision(self, raw_id: str, revision: RawRevisionEnvelope, *, manage_transaction: bool = True) -> None: + self._require_writable("bind source.db revision") return bind_raw_revision(self, raw_id, revision, manage_transaction=manage_transaction) def release_provisional_full_revisions(self, raw_ids: Sequence[str]) -> None: + self._require_writable("release source.db revisions") return release_provisional_full_revisions(self, raw_ids) def raw_full_revision_generation(self, logical_source_key: str) -> int: @@ -2550,6 +2587,7 @@ def classify_raw_revision_cohort_for_rebuild_repair( *, manage_transaction: bool = True, ) -> RevisionReplayPlan: + self._require_writable("classify source.db revision authority") return classify_raw_revision_cohort_for_rebuild_repair( self, logical_source_key, @@ -2577,6 +2615,7 @@ def classify_raw_revision_cohort_for_live_watch( *, manage_transaction: bool = True, ) -> RevisionReplayPlan: + self._require_writable("classify source.db revision authority") return classify_raw_revision_cohort_for_live_watch( self, logical_source_key, @@ -2659,6 +2698,7 @@ def replace_raw_membership_census( retire_full_revision_governance: bool = False, manage_transaction: bool = True, ) -> None: + self._require_writable("replace source.db membership census") return replace_raw_membership_census( self, raw_id, @@ -2725,6 +2765,7 @@ def defer_raw_revision_adoption( raw_ids: Sequence[str], sessions: Sequence[ParsedSession], ) -> None: + self._require_writable("defer source.db revision adoption") return defer_raw_revision_adoption(self, logical_source_key, raw_ids, sessions) def apply_raw_revision_replay( @@ -2742,6 +2783,7 @@ def apply_raw_revision_replay( skip_already_applied: bool = False, prepared_by_raw_id: dict[str, PreparedSessionRows | Future[PreparedSessionRows]] | None = None, ) -> tuple[str, tuple[str, ...]]: + self._require_writable("apply source.db revision replay") return apply_raw_revision_replay( self, plan, @@ -2772,6 +2814,7 @@ def apply_raw_membership_classification( bulk_build: bool = False, defer_fts: bool = False, ) -> str | None: + self._require_writable("apply source.db membership classification") return apply_raw_membership_classification( self, logical_source_key, @@ -2788,6 +2831,7 @@ def apply_raw_membership_classification( ) def finalize_raw_parse_state(self, raw_id: str, *, state: RawSessionStateUpdate) -> None: + self._require_writable("finalize source.db parse state") return finalize_raw_parse_state(self, raw_id, state=state) def mark_raw_parse_failed( @@ -2798,6 +2842,7 @@ def mark_raw_parse_failed( error: BaseException, preserve_existing_failure_evidence: bool = False, ) -> None: + self._require_writable("mark source.db parse failure") return mark_raw_parse_failed( self, raw_id, @@ -2816,6 +2861,7 @@ def record_raw_failure_evidence( acquired_at_ms: int, kind: RawFailureEvidenceKind, ) -> None: + self._require_writable("record source.db failure evidence") return record_raw_failure_evidence( self, raw_id, @@ -2827,6 +2873,7 @@ def record_raw_failure_evidence( ) def mark_raw_parse_succeeded(self, raw_id: str, *, provider: Provider) -> None: + self._require_writable("mark source.db parse success") return mark_raw_parse_succeeded(self, raw_id, provider=provider) def _flush_pending_raw_parse_states(self) -> None: @@ -2887,6 +2934,7 @@ def write_raw_and_parsed_result( blob_publication_receipt_id: str | None = None, finalize_raw_parse: bool = True, ) -> ArchiveRawParsedWriteResult: + self._require_writable("write source.db and index.db evidence") return write_raw_and_parsed_result( self, session, @@ -2926,6 +2974,7 @@ def admit_raw_and_parsed_result( ``logical_source_key``); use :meth:`write_raw_and_parsed_result` for callers with revision-chain/dedup semantics of their own. """ + self._require_writable("admit source.db and index.db evidence") return admit_raw_and_parsed_result( self, session, @@ -2959,6 +3008,7 @@ def write_raw_blob_and_parsed( blob_publication_receipt_id: str | None = None, finalize_raw_parse: bool = True, ) -> tuple[str, str]: + self._require_writable("write source.db blob and index.db evidence") return write_raw_blob_and_parsed( self, session, @@ -2991,6 +3041,7 @@ def write_raw_blob_and_parsed_result( blob_publication_receipt_id: str | None = None, finalize_raw_parse: bool = True, ) -> ArchiveRawParsedWriteResult: + self._require_writable("write source.db blob and index.db evidence") return write_raw_blob_and_parsed_result( self, session, @@ -4510,6 +4561,7 @@ def search_blocks(self, query: str) -> list[str]: def rebuild_index(self) -> int: """Rebuild the block FTS index from index.db blocks.""" + self._require_writable("rebuild index.db") rebuilt_rows = rebuild_archive_messages_fts(self._conn) self._conn.commit() return rebuilt_rows @@ -4570,6 +4622,7 @@ def add_user_tags( def remove_user_tags(self, session_ids: tuple[str, ...], tags: tuple[str, ...]) -> int: """Mark user tag assertions deleted and return deleted row count.""" + self._require_writable("delete user.db tags") resolved_session_ids = tuple(dict.fromkeys(self.resolve_session_id(session_id) for session_id in session_ids)) if not resolved_session_ids or not self.user_db_path.exists(): return 0 @@ -6155,6 +6208,7 @@ def delete_sessions(self, session_ids: tuple[str, ...]) -> int: left ungated deliberately rather than folding a third guard in for a cost that was never implicated by the incident. """ + self._require_writable("delete index.db sessions") resolved_session_ids = tuple(dict.fromkeys(self.resolve_session_id(session_id) for session_id in session_ids)) if not resolved_session_ids: return 0 diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index c7f78bfe7a..c085c85866 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -311,20 +311,36 @@ def initialize_archive_database( def initialize_active_archive_root(root: Path) -> None: """Create or initialize every tier database in an archive root.""" - from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation + from polylogue.storage.archive_identity import ( + ArchiveLocation, + OwnedArchiveLocation, + assert_owns_archive_location, + ) from polylogue.storage.sqlite.durable_change_train import ( _record_fresh_durable_bootstrap, _record_fresh_durable_bootstrap_intent, _validate_fresh_durable_bootstrap_intent, ) + # Ownership pins an existing directory descriptor. Fresh test and demo + # archives legitimately arrive as a not-yet-created path, so create the + # root before resolving and acquiring its identity. This is part of + # bootstrap, not an authority bypass: the descriptor is still acquired + # and checked before any tier is initialized. + root.mkdir(mode=0o700, parents=True, exist_ok=True) with OwnedArchiveLocation.acquire( ArchiveLocation.resolve(root), owner_id=f"bootstrap:{os.getpid()}", allow_reentrant=True, - ): + ) as owned: + + def assert_owned_root() -> None: + """Refuse pathname writes after the owned root has been replaced.""" + assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) + # Classify the archive after acquiring ownership. Another process may # publish a marker or durable train while the probe is in flight. + assert_owned_root() durable_tier_exists = any( (root / archive_tier_spec(tier).filename).exists() for tier in DURABLE_MIGRATION_TIERS ) @@ -358,22 +374,29 @@ def initialize_active_archive_root(root: Path) -> None: and not has_pending_bootstrap ) if fresh_durable_bootstrap: + assert_owned_root() _record_fresh_durable_bootstrap_intent(root) if not recovering_fresh_durable_bootstrap and not pre_marker_adoption: + assert_owned_root() reconcile_durable_change_trains_on_startup(root) for spec in ARCHIVE_TIER_SPECS.values(): + assert_owned_root() initialize_archive_database(root / spec.filename, spec.tier) if recovering_fresh_durable_bootstrap: + assert_owned_root() _record_fresh_durable_bootstrap(root) elif pre_marker_adoption: from polylogue.storage.sqlite.durable_change_train import _adopt_pre_marker_durable_bootstrap + assert_owned_root() _adopt_pre_marker_durable_bootstrap(root) + assert_owned_root() reconcile_durable_change_trains_on_startup(root) elif has_pending_bootstrap: # A crash after publishing the completed marker but before # removing the intent is harmless. Keep the intent until the # completed marker has passed normal startup reconciliation. + assert_owned_root() pending_bootstrap_path.unlink(missing_ok=True) diff --git a/polylogue/storage/sqlite/connection_profile.py b/polylogue/storage/sqlite/connection_profile.py index 5dd844adc2..a928b25bcb 100644 --- a/polylogue/storage/sqlite/connection_profile.py +++ b/polylogue/storage/sqlite/connection_profile.py @@ -15,6 +15,7 @@ from __future__ import annotations +import os import sqlite3 from collections.abc import Iterator from contextlib import contextmanager @@ -539,11 +540,29 @@ def open_daemon_connection( return conn +def _descriptor_database_uri(opened_main_fd: int, suffix: str) -> str | None: + """Return a validated descriptor URI on platforms that expose one.""" + descriptor_metadata = os.fstat(opened_main_fd) + for directory in ("/dev/fd", "/proc/self/fd"): + candidate = f"{directory}/{opened_main_fd}" + try: + alias_metadata = os.stat(candidate) + except OSError: + continue + if (alias_metadata.st_dev, alias_metadata.st_ino) == ( + descriptor_metadata.st_dev, + descriptor_metadata.st_ino, + ): + return f"file:{candidate}{suffix}" + return None + + def open_readonly_connection( path: str | Path, *, timeout: float = READ_DB_TIMEOUT, immutable: bool = False, + opened_main_fd: int | None = None, ) -> sqlite3.Connection: """Open a read-only SQLite connection with canonical read pragmas applied. @@ -561,9 +580,23 @@ def open_readonly_connection( Callers passing ``immutable=True`` own that precondition check; this helper does not perform it, since the check is specific to how the caller obtained the snapshot. + + When ``opened_main_fd`` is supplied, the reader is bound to that opened + inode through a validated ``/dev/fd`` or ``/proc/self/fd`` alias. A caller + that needs descriptor binding fails closed when neither alias is available. """ suffix = "?mode=ro&immutable=1" if immutable else "?mode=ro" - conn = sqlite3.connect(f"file:{path}{suffix}", uri=True, timeout=timeout) + if opened_main_fd is not None and immutable: + raise ValueError("an opened SQLite file descriptor cannot use immutable mode") + opened_fd = opened_main_fd + if opened_fd is None: + database_uri = f"file:{path}{suffix}" + else: + descriptor_uri = _descriptor_database_uri(opened_fd, suffix) + if descriptor_uri is None: + raise RuntimeError(f"cannot open selected SQLite database through a descriptor-bound path: {path}") + database_uri = descriptor_uri + conn = sqlite3.connect(database_uri, uri=True, timeout=timeout) try: for stmt in READ_CONNECTION_PRAGMA_STATEMENTS: conn.execute(stmt) diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index ac3bb4dd88..d56fdd2f20 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -5,7 +5,9 @@ import itertools import json import os +import shutil import sqlite3 +import stat import subprocess import time from pathlib import Path @@ -20,6 +22,7 @@ from polylogue.core.enums import Provider from polylogue.core.json import json_document from polylogue.maintenance.replay import rebuild_index_from_source +from polylogue.sources.revision_backfill import census_historical_revision_evidence from polylogue.storage.blob_gc import read_gc_history from polylogue.storage.blob_publication import ArchiveBlobPublisher from polylogue.storage.raw_authority import RawReplayPlan, record_raw_authority_census @@ -30,6 +33,7 @@ ) from polylogue.storage.sqlite.archive_tiers.archive_plan import ArchiveInitAction, ArchiveInitPlan from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS, initialize_archive_tier +from polylogue.storage.sqlite.archive_tiers.source import SOURCE_SCHEMA_VERSION from polylogue.storage.sqlite.archive_tiers.source_write import write_source_raw_session_blob_ref from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.archive_tiers.user import USER_SCHEMA_VERSION @@ -431,6 +435,10 @@ def _stage_uninitialized_archive(cli_workspace: dict[str, Path]) -> None: archive_root = cli_workspace["archive_root"] for name in _ARCHIVE_TIERS: (archive_root / name).unlink(missing_ok=True) + shutil.rmtree( + archive_root / ".maintenance-state" / "durable-change-trains", + ignore_errors=True, + ) def _write_gc_candidate(cli_workspace: dict[str, Path], blob_hash: str) -> Path: @@ -510,6 +518,37 @@ def _create_user_v3(path: Path) -> None: PRAGMA user_version = 3; """ ) + _refresh_fresh_bootstrap_marker(path.parent) + + +def _refresh_fresh_bootstrap_marker(archive_root: Path) -> None: + """Rebind a fixture bootstrap receipt after deliberate durable-tier edits.""" + marker = archive_root / ".maintenance-state" / "durable-change-trains" / ".bootstrap" + assert marker.is_file(), f"fixture must carry a fresh bootstrap marker: {marker}" + from polylogue.storage.sqlite.durable_change_train import _record_fresh_durable_bootstrap + + marker.unlink() + _record_fresh_durable_bootstrap(archive_root) + + +def _freeze_rebuild_fixture_source(archive_root: Path, *, expected_raws: int) -> None: + """Census fixture raws, then record their explicit single-revision decision.""" + census = census_historical_revision_evidence(archive_root) + assert census.scanned == expected_raws + assert census.classified_full == expected_raws + with sqlite3.connect(archive_root / "source.db") as source: + source.execute( + """ + UPDATE raw_sessions + SET revision_authority = 'byte_proven', + revision_kind = 'full', + source_revision = raw_id, + baseline_raw_id = raw_id, + predecessor_raw_id = NULL, + acquisition_generation = 0 + """ + ) + source.commit() def _run_verified_backup_cli(cli_runner: CliRunner, output_dir: Path, *, profile: str) -> Path: @@ -1517,6 +1556,7 @@ def test_migrate_tier_cli_executes_and_persists_a_future_change_train( conn.execute("CREATE TABLE base_items (item_id TEXT PRIMARY KEY, payload TEXT NOT NULL) STRICT") conn.execute("PRAGMA user_version = 1") conn.commit() + _refresh_fresh_bootstrap_marker(cli_workspace["archive_root"]) result = cli_runner.invoke( cli, @@ -2139,9 +2179,7 @@ def test_rebuild_index_preflight_reports_durable_schema_currency( ) -> None: root = cli_workspace["archive_root"] with sqlite3.connect(root / "source.db") as conn: - conn.execute("DROP INDEX idx_raw_failure_disposition_receipts_disposed_at") - conn.execute("DROP TABLE raw_failure_disposition_receipts") - conn.execute("PRAGMA user_version = 28") + conn.execute(f"PRAGMA user_version = {SOURCE_SCHEMA_VERSION - 1}") result = cli_runner.invoke( cli, @@ -2155,16 +2193,20 @@ def test_rebuild_index_preflight_reports_durable_schema_currency( assert payload["status"] == "blocked" assert [tier["tier"] for tier in payload["tiers"]] == ["audit", "source", "user"] assert payload["blocking_tiers"][0]["tier"] == "source" - assert payload["blocking_tiers"][0]["actual_user_version"] == 28 - assert payload["blocking_tiers"][0]["expected_user_version"] == 29 + assert payload["blocking_tiers"][0]["actual_user_version"] == SOURCE_SCHEMA_VERSION - 1 + assert payload["blocking_tiers"][0]["expected_user_version"] == SOURCE_SCHEMA_VERSION assert "migrate or deploy before rebuilding" in result.stderr def test_migrate_tier_cli_initializes_only_an_absent_durable_tier( cli_workspace: dict[str, Path], cli_runner: CliRunner ) -> None: + _stage_uninitialized_archive(cli_workspace) + blob_root = cli_workspace["archive_root"] / "blob" + blob_root.mkdir() + assert blob_root.is_dir() + assert not any(blob_root.iterdir()) audit_db = cli_workspace["archive_root"] / "audit.db" - audit_db.unlink() result = cli_runner.invoke( cli, @@ -2222,8 +2264,8 @@ def test_migrate_tier_cli_missing_initialization_refuses_an_existing_tier( def test_migrate_tier_cli_missing_initialization_loses_publish_race_without_replacement( cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch ) -> None: + _stage_uninitialized_archive(cli_workspace) audit_db = cli_workspace["archive_root"] / "audit.db" - audit_db.unlink() raced_bytes = b"concurrent durable owner\n" real_link = os.link @@ -2235,7 +2277,12 @@ def create_target_before_publish( dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: - Path(destination).write_bytes(raced_bytes) + assert dst_dir_fd is not None + target_fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=dst_dir_fd) + try: + os.write(target_fd, raced_bytes) + finally: + os.close(target_fd) real_link( source, destination, @@ -2267,35 +2314,569 @@ def create_target_before_publish( assert not list(audit_db.parent.glob(".audit.db.initialize-*.tmp")) -def test_migrate_tier_cli_exposes_no_named_staging_inode_before_publication( +def test_migrate_tier_cli_rejects_archive_directory_swap_before_publication( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + moved_root = root.with_name("archive-moved") + swapped = False + + def swap_after_archive_ownership(_root: Path) -> str: + nonlocal swapped + if not swapped: + root.rename(moved_root) + root.mkdir() + swapped = True + return "proof:daemon-stopped" + + monkeypatch.setattr( + "polylogue.cli.commands.maintenance._migrate_tier._require_stopped_daemon", + swap_after_archive_ownership, + ) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "changed during validation" in json.loads(result.stdout)["error"] + assert not (root / "audit.db").exists() + assert not (moved_root / "audit.db").exists() + + +def test_migrate_tier_cli_wraps_non_collision_publication_error( cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch ) -> None: + _stage_uninitialized_archive(cli_workspace) audit_db = cli_workspace["archive_root"] / "audit.db" - audit_db.unlink() - real_link = os.link - def assert_no_named_stage_before_publish( - source: os.PathLike[str] | str, - destination: os.PathLike[str] | str, + def fail_link(*_args: object, **_kwargs: object) -> None: + raise OSError("cross-device link") + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.link", fail_link) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + error = json.loads(result.stdout)["error"] + assert f"cannot initialize missing audit tier: anonymous durable publication failed: {audit_db}" in error + assert not audit_db.exists() + + +@pytest.mark.parametrize("failure_stage", ["image_fsync", "directory_open", "directory_fsync"]) +def test_migrate_tier_cli_cleans_up_after_publication_failure( + cli_workspace: dict[str, Path], + cli_runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + failure_stage: str, +) -> None: + """The real publication route leaves no owned target after a failure.""" + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + audit_db = root / "audit.db" + module_name = "polylogue.operations.durable_change_train" + + real_fsync = os.fsync + real_open = os.open + publication_descriptor: int | None = None + + def fail_fsync(descriptor: int) -> None: + is_directory = stat.S_ISDIR(os.fstat(descriptor).st_mode) + if failure_stage == "image_fsync" and descriptor == publication_descriptor: + raise OSError(f"{failure_stage} failed") + if failure_stage == "directory_fsync" and is_directory: + raise OSError(f"{failure_stage} failed") + real_fsync(descriptor) + + monkeypatch.setattr(f"{module_name}.os.fsync", fail_fsync) + dup_failure_armed = False + + from polylogue.cli.commands.maintenance import _migrate_tier + + real_require_stopped_daemon = _migrate_tier._require_stopped_daemon + + def arm_dup_failure(path: Path) -> str: + nonlocal dup_failure_armed + dup_failure_armed = True + return real_require_stopped_daemon(path) + + monkeypatch.setattr(_migrate_tier, "_require_stopped_daemon", arm_dup_failure) + + def record_publication_open( + file: os.PathLike[str] | str, + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + nonlocal publication_descriptor + descriptor = real_open(file, flags, mode, dir_fd=dir_fd) + tmpfile_flag = getattr(os, "O_TMPFILE", 0) + if tmpfile_flag and (flags & tmpfile_flag) == tmpfile_flag: + publication_descriptor = descriptor + return descriptor + + monkeypatch.setattr(f"{module_name}.os.open", record_publication_open) + + real_dup = os.dup + + def fail_directory_dup(descriptor: int) -> int: + if failure_stage == "directory_open" and dup_failure_armed: + raise OSError("directory open failed") + return real_dup(descriptor) + + monkeypatch.setattr(f"{module_name}.os.dup", fail_directory_dup) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert f"cannot publish audit tier at {audit_db}" in json.loads(result.stdout)["error"] + if failure_stage == "directory_fsync": + assert audit_db.exists() + else: + assert not audit_db.exists() + + +def test_migrate_tier_cli_serializes_cleanup_fsync_uncertainty( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + """A cleanup fsync fault is durable-recovery uncertainty, not a lost note.""" + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + audit_db = root / "audit.db" + real_stat = os.stat + published_stat_calls = 0 + + def fail_published_stat( + file: os.PathLike[str] | str, + *, + dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> os.stat_result: + nonlocal published_stat_calls + if file == "audit.db" and dir_fd is not None: + published_stat_calls += 1 + if published_stat_calls == 2: + raise OSError("published identity fault") + return real_stat(file, dir_fd=dir_fd, follow_symlinks=follow_symlinks) + + real_fsync = os.fsync + + def fail_cleanup_fsync(descriptor: int) -> None: + if stat.S_ISDIR(os.fstat(descriptor).st_mode): + raise OSError("cleanup fsync fault") + real_fsync(descriptor) + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.stat", fail_published_stat) + monkeypatch.setattr("polylogue.operations.durable_change_train.os.fsync", fail_cleanup_fsync) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["durable_recovery"] == { + "code": "cleanup_not_atomic", + "detail": ( + f"published durable tier remains after publication failure; cleanup deferred because no conditional " + f"inode removal is available: {audit_db}" + ), + "state": "uncertain", + "target": str(audit_db), + } + assert audit_db.exists() + + +def test_migrate_tier_cli_plain_output_reports_uncertain_durable_recovery( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_uninitialized_archive(cli_workspace) + from polylogue.cli.commands.maintenance import _migrate_tier + from polylogue.operations.durable_change_train import DurableCleanupOutcome, DurablePublicationError + + cleanup = DurableCleanupOutcome( + "uncertain", + "cleanup_not_atomic", + str(cli_workspace["archive_root"] / "audit.db"), + "publication target requires manual inspection", + ) + + def refuse_initialization(*args: object, **kwargs: object) -> int: + del args, kwargs + raise DurablePublicationError("publication blocked", cleanup=cleanup) + + monkeypatch.setattr(_migrate_tier, "initialize_missing_durable_tier", refuse_initialization) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "plain", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert ( + "Durable recovery required (cleanup_not_atomic): publication target requires manual inspection" in result.output + ) + + +def test_missing_tier_initialization_closes_directory_after_publication_descriptor_close_failure( + cli_workspace: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + from polylogue.operations import durable_change_train + from polylogue.operations.durable_change_train import ( + DurablePublicationError, + acquire_durable_archive_ownership, + initialize_missing_durable_tier, + ) + + publication_descriptor: int | None = None + publication_close_failed = False + directory_closed = False + real_open = os.open + real_close = os.close + + def record_publication_open( + file: os.PathLike[str] | str, + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + nonlocal publication_descriptor + descriptor = real_open(file, flags, mode, dir_fd=dir_fd) + tmpfile_flag = getattr(os, "O_TMPFILE", 0) + if tmpfile_flag and (flags & tmpfile_flag) == tmpfile_flag: + publication_descriptor = descriptor + return descriptor + + def fail_publication_close(descriptor: int) -> None: + nonlocal directory_closed, publication_close_failed + if descriptor == publication_descriptor and not publication_close_failed: + publication_close_failed = True + raise OSError("publication close failed") + if stat.S_ISDIR(os.fstat(descriptor).st_mode): + directory_closed = True + real_close(descriptor) + + owner = acquire_durable_archive_ownership(root, owner_id="publication-close-test") + try: + with monkeypatch.context() as scoped: + scoped.setattr("polylogue.operations.durable_change_train.os.open", record_publication_open) + scoped.setattr(durable_change_train, "_close_publication_descriptor", fail_publication_close) + with pytest.raises(DurablePublicationError) as raised: + initialize_missing_durable_tier( + root / "audit.db", + ArchiveTier.AUDIT, + directory_fd=owner.directory_fd, + ) + finally: + if publication_descriptor is not None and publication_close_failed: + real_close(publication_descriptor) + owner.release() + + assert directory_closed is True + assert raised.value.cleanup is not None + assert raised.value.cleanup.state == "uncertain" + + +def test_migrate_tier_cli_serializes_target_absent_cleanup( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + """A vanished publication target is reported distinctly from cleanup uncertainty.""" + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + audit_db = root / "audit.db" + real_stat = os.stat + published_stat_calls = 0 + + def remove_target_before_cleanup_stat( + file: os.PathLike[str] | str, + *, + dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> os.stat_result: + nonlocal published_stat_calls + if file == "audit.db" and dir_fd is not None: + published_stat_calls += 1 + if published_stat_calls == 3: + audit_db.unlink() + return real_stat(file, dir_fd=dir_fd, follow_symlinks=follow_symlinks) + + real_fsync = os.fsync + directory_fsyncs = 0 + + def fail_after_publish(descriptor: int) -> None: + nonlocal directory_fsyncs + if stat.S_ISDIR(os.fstat(descriptor).st_mode): + directory_fsyncs += 1 + if directory_fsyncs == 1: + raise OSError("publication fsync fault") + real_fsync(descriptor) + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.stat", remove_target_before_cleanup_stat) + monkeypatch.setattr("polylogue.operations.durable_change_train.os.fsync", fail_after_publish) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["durable_recovery"] == { + "code": None, + "detail": None, + "state": "target_absent", + "target": str(audit_db), + } + + +def test_migrate_tier_cli_preserves_replacement_during_checked_leaf_cleanup( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + """The cleanup mutation swaps in a foreign leaf before the checked unlink.""" + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + audit_db = root / "audit.db" + foreign = root / "foreign-audit.db" + real_stat = os.stat + published_stat_calls = 0 + + def replace_target_before_checked_unlink( + file: os.PathLike[str] | str, + *, + dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> os.stat_result: + nonlocal published_stat_calls + if file == "audit.db" and dir_fd is not None: + published_stat_calls += 1 + if published_stat_calls == 3: + audit_db.unlink() + foreign.write_bytes(b"foreign target") + foreign.rename(audit_db) + return real_stat(file, dir_fd=dir_fd, follow_symlinks=follow_symlinks) + + real_fsync = os.fsync + directory_fsyncs = 0 + + def fail_after_publish(descriptor: int) -> None: + nonlocal directory_fsyncs + if stat.S_ISDIR(os.fstat(descriptor).st_mode): + directory_fsyncs += 1 + if directory_fsyncs == 1: + raise OSError("publication fsync fault") + real_fsync(descriptor) + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.stat", replace_target_before_checked_unlink) + monkeypatch.setattr("polylogue.operations.durable_change_train.os.fsync", fail_after_publish) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["durable_recovery"]["code"] == "leaf_replaced" + assert audit_db.read_bytes() == b"foreign target" + + +def test_migrate_tier_cli_preserves_replacement_after_cleanup_final_check( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + """The cleanup rename must not unlink a leaf swapped after its final check.""" + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + audit_db = root / "audit.db" + rename_calls = 0 + + def reject_unsafe_cleanup_rename( + source: str | os.PathLike[str], + destination: str | os.PathLike[str], *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, - follow_symlinks: bool = True, ) -> None: - assert not list(audit_db.parent.glob(".audit.db.initialize-*.tmp")) - real_link( - source, - destination, - src_dir_fd=src_dir_fd, - dst_dir_fd=dst_dir_fd, - follow_symlinks=follow_symlinks, - ) + nonlocal rename_calls + rename_calls += 1 + raise AssertionError(f"unsafe cleanup rename attempted: {source} -> {destination}") + + real_fsync = os.fsync + directory_fsyncs = 0 + + def fail_after_publish(descriptor: int) -> None: + nonlocal directory_fsyncs + if stat.S_ISDIR(os.fstat(descriptor).st_mode): + directory_fsyncs += 1 + if directory_fsyncs == 1: + raise OSError("publication fsync fault") + real_fsync(descriptor) + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.rename", reject_unsafe_cleanup_rename) + monkeypatch.setattr("polylogue.operations.durable_change_train.os.fsync", fail_after_publish) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) - monkeypatch.setattr( - "polylogue.operations.durable_change_train.os.link", - assert_no_named_stage_before_publish, + assert result.exit_code == 1 + assert json.loads(result.stdout)["durable_recovery"]["code"] == "cleanup_not_atomic" + assert audit_db.exists() + assert rename_calls == 0 + + +def test_migrate_tier_cli_serializes_cleanup_inspection_uncertainty( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + """Cleanup inspection failure remains typed while the publication error survives.""" + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + audit_db = root / "audit.db" + real_stat = os.stat + target_stat_calls = 0 + + def fail_cleanup_inspection( + file: os.PathLike[str] | str, + *, + dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> os.stat_result: + nonlocal target_stat_calls + if file == "audit.db" and dir_fd is not None: + target_stat_calls += 1 + if target_stat_calls == 3: + raise OSError("cleanup inspection fault") + return real_stat(file, dir_fd=dir_fd, follow_symlinks=follow_symlinks) + + real_fsync = os.fsync + directory_fsyncs = 0 + + def fail_after_publish(descriptor: int) -> None: + nonlocal directory_fsyncs + if stat.S_ISDIR(os.fstat(descriptor).st_mode): + directory_fsyncs += 1 + if directory_fsyncs == 1: + raise OSError("publication fsync fault") + real_fsync(descriptor) + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.stat", fail_cleanup_inspection) + monkeypatch.setattr("polylogue.operations.durable_change_train.os.fsync", fail_after_publish) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, ) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert "cannot publish audit tier" in payload["error"] + assert payload["durable_recovery"]["code"] == "leaf_inspection_failed" + assert "could not inspect published durable tier" in payload["durable_recovery"]["detail"] + assert audit_db.exists() + + +def test_migrate_tier_cli_fails_closed_when_anonymous_publication_is_unavailable( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_uninitialized_archive(cli_workspace) + audit_db = cli_workspace["archive_root"] / "audit.db" + monkeypatch.setattr(os, "O_TMPFILE", 0, raising=False) + result = cli_runner.invoke( cli, [ @@ -2311,13 +2892,348 @@ def assert_no_named_stage_before_publish( catch_exceptions=False, ) - assert result.exit_code == 0, result.output - with sqlite3.connect(audit_db) as conn: - assert conn.execute("PRAGMA user_version").fetchone() == (1,) - assert conn.execute("PRAGMA integrity_check").fetchone() == ("ok",) + assert result.exit_code == 1 + assert "filesystem does not support O_TMPFILE" in json.loads(result.stdout)["error"] + assert not audit_db.exists() assert not list(audit_db.parent.glob(".audit.db.initialize-*.tmp")) +@pytest.mark.parametrize( + ("missing_name", "sibling_name"), + [("source.db", "user.db"), ("user.db", "source.db"), ("audit.db", "source.db")], +) +def test_migrate_tier_cli_refuses_to_initialize_a_tier_in_an_established_archive( + cli_workspace: dict[str, Path], + cli_runner: CliRunner, + missing_name: str, + sibling_name: str, +) -> None: + root = cli_workspace["archive_root"] + missing = root / missing_name + missing.unlink() + before = missing.exists() + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + missing.stem, + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "established archive" in json.loads(result.stdout)["error"] + assert missing.exists() is before + assert (root / sibling_name).exists() + + +@pytest.mark.parametrize("missing_name", ["source.db", "user.db"]) +@pytest.mark.parametrize( + "retained_evidence", + [ + "index.db", + ".index-generations", + ".index-rebuild-transactions", + "source-continuity-pending", + "source-continuity-refreshes", + "operation.json", + "failures.jsonl", + ], +) +def test_migrate_tier_cli_missing_initialization_refuses_retained_archive_evidence( + cli_workspace: dict[str, Path], cli_runner: CliRunner, missing_name: str, retained_evidence: str +) -> None: + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + missing_path = root / missing_name + retained_path = root / retained_evidence + if retained_evidence == "index.db": + retained_path.touch() + elif retained_evidence in {"source-continuity-pending", "source-continuity-refreshes"}: + retained_path = root / ".maintenance-state" / retained_evidence + retained_path.mkdir(parents=True) + (retained_path / "intent.json").write_text("{}", encoding="utf-8") + elif retained_evidence in {"operation.json", "failures.jsonl"}: + retained_path = root / ".maintenance-state" / retained_evidence + retained_path.parent.mkdir(parents=True, exist_ok=True) + retained_path.write_text("{}", encoding="utf-8") + else: + retained_path.mkdir(parents=True) + (retained_path / "retained.json").write_text("{}", encoding="utf-8") + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + missing_path.stem, + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "established archive" in json.loads(result.stdout)["error"] + assert retained_path.exists() + assert not missing_path.exists() + + +def test_migrate_tier_cli_rechecks_adoption_evidence_before_publication( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + """A maintenance record appearing during image build must block the visible link.""" + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + audit_db = root / "audit.db" + from polylogue.storage.sqlite.archive_tiers import bootstrap + + real_initialize = bootstrap.initialize_archive_tier + + def establish_archive_during_build(connection: sqlite3.Connection, tier: ArchiveTier) -> None: + real_initialize(connection, tier) + maintenance_state = root / ".maintenance-state" + maintenance_state.mkdir(exist_ok=True) + (maintenance_state / "failures.jsonl").write_text('{"operation":"interrupted"}\n', encoding="utf-8") + + monkeypatch.setattr(bootstrap, "initialize_archive_tier", establish_archive_during_build) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "established archive" in json.loads(result.stdout)["error"] + assert (root / ".maintenance-state" / "failures.jsonl").exists() + assert not audit_db.exists() + + +@pytest.mark.parametrize("blob_state", ["nonempty-directory", "regular-file"]) +def test_migrate_tier_cli_missing_initialization_refuses_retained_blob_evidence( + cli_workspace: dict[str, Path], cli_runner: CliRunner, blob_state: str +) -> None: + _stage_uninitialized_archive(cli_workspace) + blob_root = cli_workspace["archive_root"] / "blob" + blob_root.mkdir() + if blob_state == "nonempty-directory": + (blob_root / "retained-entry").write_bytes(b"retained") + else: + blob_root.rmdir() + blob_root.write_bytes(b"malformed blob store") + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "established archive" in json.loads(result.stdout)["error"] + assert not (cli_workspace["archive_root"] / "audit.db").exists() + + +@pytest.mark.parametrize("marker_name", [".bootstrap", ".bootstrap.pending"]) +def test_migrate_tier_cli_missing_initialization_refuses_bootstrap_markers( + cli_workspace: dict[str, Path], cli_runner: CliRunner, marker_name: str +) -> None: + _stage_uninitialized_archive(cli_workspace) + marker_root = cli_workspace["archive_root"] / ".maintenance-state" / "durable-change-trains" + marker_root.mkdir(parents=True) + (marker_root / marker_name).write_text("marker", encoding="utf-8") + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + error = json.loads(result.stdout)["error"] + assert "established archive" in error + assert str(marker_root / marker_name) in error + assert not (cli_workspace["archive_root"] / "audit.db").exists() + + +def test_migrate_tier_cli_missing_initialization_refuses_blob_inspection_failure( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + blob_root = root / "blob" + blob_root.mkdir() + blob_identity = blob_root.stat() + real_listdir = os.listdir + + def fail_blob_inspection(candidate: int | os.PathLike[str] | str) -> list[str]: + if isinstance(candidate, int): + candidate_metadata = os.fstat(candidate) + if (candidate_metadata.st_dev, candidate_metadata.st_ino) == (blob_identity.st_dev, blob_identity.st_ino): + raise OSError("blob inspection failed") + return real_listdir(candidate) + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.listdir", fail_blob_inspection) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "cannot inspect retained blob path" in json.loads(result.stdout)["error"] + assert not (root / "audit.db").exists() + + +def test_migrate_tier_cli_missing_initialization_refuses_marker_inspection_failure( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + marker_root = root / ".maintenance-state" / "durable-change-trains" + marker_root.mkdir(parents=True) + real_stat = os.stat + + def fail_marker_inspection( + candidate: os.PathLike[str] | str, + *, + dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> os.stat_result: + if candidate == ".maintenance-state/durable-change-trains" and dir_fd is not None: + raise OSError("marker inspection failed") + return real_stat(candidate, dir_fd=dir_fd, follow_symlinks=follow_symlinks) + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.stat", fail_marker_inspection) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "cannot inspect durable change-train adoption marker" in json.loads(result.stdout)["error"] + assert not (root / "audit.db").exists() + + +def test_migrate_tier_cli_missing_initialization_refuses_dangling_active_pointer( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + pointer = root / ".index-active-pointer" + pointer.symlink_to(root / ".index-generations" / "missing" / "index.db") + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + error = json.loads(result.stdout)["error"] + assert "established archive" in error + assert str(pointer) in error + assert not (root / "audit.db").exists() + + +def test_migrate_tier_cli_missing_initialization_refuses_malformed_train_marker( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + _stage_uninitialized_archive(cli_workspace) + root = cli_workspace["archive_root"] + marker = root / ".maintenance-state" / "durable-change-trains" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("not a marker directory", encoding="utf-8") + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + error = json.loads(result.stdout)["error"] + assert "established archive" in error + assert str(marker) in error + assert not (root / "audit.db").exists() + + def test_rebuild_index_empty_source_still_runs_the_schema_currency_guard( cli_workspace: dict[str, Path], cli_runner: CliRunner ) -> None: @@ -2337,8 +3253,8 @@ def test_rebuild_index_empty_source_still_runs_the_schema_currency_guard( assert not (root / ".index-generations").exists() -def test_rebuild_index_empty_source_preserves_plain_receipt_output_after_guard( - cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +def test_rebuild_index_empty_source_preserves_plain_receipt_output_without_schema_receipt( + cli_workspace: dict[str, Path], cli_runner: CliRunner ) -> None: """The real empty receipt must render without replay-only counter keys. @@ -2346,8 +3262,6 @@ def test_rebuild_index_empty_source_preserves_plain_receipt_output_after_guard( formatter and raises KeyError before this exact plain output is emitted. """ root = cli_workspace["archive_root"] - receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) result = cli_runner.invoke( cli, @@ -2583,6 +3497,7 @@ def test_rebuild_index_full_source_resumes_one_candidate_until_terminal_promotio """ ) source.commit() + _freeze_rebuild_fixture_source(root, expected_raws=2) receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) @@ -2591,7 +3506,7 @@ def test_rebuild_index_full_source_resumes_one_candidate_until_terminal_promotio ["--plain", "ops", "maintenance", "rebuild-index", "--raw-batch-size", "1", "--output-format", "json"], catch_exceptions=False, ) - assert first.exit_code == 0 + assert first.exit_code == 0, first.output # This pass now also replays a raw page through the shared # revision-backfill machinery, which logs "backfill stage timings" to # stderr on every call (see the sibling terminal-promotion test for the @@ -2679,6 +3594,7 @@ def test_rebuild_index_persists_durable_pass_receipt_alongside_transaction( """ ) source.commit() + _freeze_rebuild_fixture_source(root, expected_raws=2) receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) @@ -2687,7 +3603,7 @@ def test_rebuild_index_persists_durable_pass_receipt_alongside_transaction( ["--plain", "ops", "maintenance", "rebuild-index", "--raw-batch-size", "1", "--output-format", "json"], catch_exceptions=False, ) - assert first.exit_code == 0 + assert first.exit_code == 0, first.output # This pass now also replays a raw page through the shared # revision-backfill machinery, which logs "backfill stage timings" to # stderr on every call (see the sibling terminal-promotion test for the @@ -2752,6 +3668,7 @@ def test_rebuild_index_byte_budget_defers_then_reaches_terminal_ready_candidate( source_path=f"{native_id}.jsonl", acquired_at_ms=acquired_at_ms, ) + _freeze_rebuild_fixture_source(root, expected_raws=2) receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) first = cli_runner.invoke( @@ -2769,7 +3686,7 @@ def test_rebuild_index_byte_budget_defers_then_reaches_terminal_ready_candidate( ], catch_exceptions=False, ) - assert first.exit_code == 0 + assert first.exit_code == 0, first.output # This pass now also replays a raw page through the shared # revision-backfill machinery, which logs "backfill stage timings" to # stderr on every call (see the sibling terminal-promotion test for the @@ -2862,6 +3779,7 @@ def test_rebuild_index_deadline_defers_postflight_until_resume( source_path="deadline.jsonl", acquired_at_ms=1, ) + _freeze_rebuild_fixture_source(root, expected_raws=1) receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) # `monkeypatch.setattr("polylogue.maintenance.rebuild_index.time.time", ...)` @@ -2898,7 +3816,7 @@ def test_rebuild_index_deadline_defers_postflight_until_resume( ], catch_exceptions=False, ) - assert first.exit_code == 0 + assert first.exit_code == 0, first.output # This pass replays a raw page through the shared revision-backfill # machinery, which logs "backfill stage timings" to stderr on every # call; `.stdout` is the actual `--output-format json` contract diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index af236c5bcc..9d92aa33fd 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -6,6 +6,7 @@ import inspect import os import sqlite3 +import stat import threading import time from pathlib import Path @@ -120,7 +121,7 @@ def test_polylogued_status_json_reports_daemon_components( ], ) - assert result.exit_code == 0 + assert result.exit_code == 1 payload = loads(result.output) assert isinstance(payload, dict) live = cast(JSONDocument, payload["live"]) @@ -138,7 +139,7 @@ def test_polylogued_status_plain_reports_daemon_components(tmp_path: Path) -> No with patch("polylogue.daemon.status.default_sources", return_value=sources): result = CliRunner().invoke(main, ["status"]) - assert result.exit_code == 0 + assert result.exit_code == 1 assert "Polylogue daemon" in result.output assert "Live sources: 1/1 available" in result.output assert f"exists: {tmp_path} (available)" in result.output @@ -302,7 +303,7 @@ def test_polylogued_status_plain_reports_archive_storage(tmp_path: Path) -> None ): result = CliRunner().invoke(main, ["status"]) - assert result.exit_code == 0 + assert result.exit_code == 1 assert "Storage: archive_file_set (source, index); missing embeddings, user, ops" in result.output @@ -3478,6 +3479,46 @@ def schema_ok() -> HealthAlert: assert not (tmp_path / "daemon.pid").exists() +def test_daemon_startup_creates_missing_archive_root_before_ownership( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The live daemon route must own a first-run root, not require a prior bootstrap.""" + from polylogue.daemon import cli as daemon_cli + + archive = tmp_path / "first-run-archive" + monkeypatch.setattr("polylogue.paths.archive_root", lambda: archive) + + def stop_after_ownership(root: Path) -> tuple[Path, ...]: + assert root == archive + assert archive.is_dir() + raise RuntimeError("owned first-run archive") + + monkeypatch.setattr( + "polylogue.operations.durable_change_train.reconcile_durable_change_trains_on_startup", + stop_after_ownership, + ) + + previous_umask = os.umask(0) + try: + with pytest.raises(RuntimeError, match="owned first-run archive"): + asyncio.run( + daemon_cli.run_daemon_services( + sources=(), + debounce_s=1.0, + enable_watch=False, + enable_browser_capture=False, + browser_capture_host="127.0.0.1", + browser_capture_port=8765, + browser_capture_spool_path=None, + ) + ) + finally: + os.umask(previous_umask) + + assert stat.S_IMODE(archive.stat().st_mode) == 0o700 + assert (archive / ".archive-ownership.lock").exists() + + def test_run_daemon_services_checks_archive_identity_before_component_startup(tmp_path: Path) -> None: from polylogue.daemon import cli as daemon_cli from polylogue.storage.archive_identity import ArchiveIdentityConflictError @@ -4185,9 +4226,11 @@ async def fake_run_pass(**kwargs: object) -> None: monkeypatch.setattr("polylogue.paths.archive_root", lambda: tmp_path) monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") - monkeypatch.setattr( - "polylogue.daemon.bulk_rebuild.has_resumable_daemon_bulk_rebuild_transaction", lambda _root: True - ) + + async def resumable_transaction_in_flight() -> bool: + return True + + monkeypatch.setattr(daemon_cli, "_daemon_bulk_rebuild_transaction_in_flight", resumable_transaction_in_flight) monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_bulk_rebuild_pass", fake_run_pass) counts = RawMaterializationCounts(candidate_count=3, pending_blob_bytes=0) @@ -4212,9 +4255,11 @@ async def fail_run_pass(**_kwargs: object) -> object: monkeypatch.setattr("polylogue.paths.archive_root", lambda: tmp_path) monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") - monkeypatch.setattr( - "polylogue.daemon.bulk_rebuild.has_resumable_daemon_bulk_rebuild_transaction", lambda _root: True - ) + + async def resumable_transaction_in_flight() -> bool: + return True + + monkeypatch.setattr(daemon_cli, "_daemon_bulk_rebuild_transaction_in_flight", resumable_transaction_in_flight) monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_bulk_rebuild_pass", fail_run_pass) counts = RawMaterializationCounts(candidate_count=3, pending_blob_bytes=0) @@ -4230,18 +4275,32 @@ def test_daemon_bulk_rebuild_transaction_in_flight_delegates( from polylogue.daemon import cli as daemon_cli seen_roots: list[Path] = [] + validated_receipts: list[tuple[Path, Path]] = [] + receipt_path = tmp_path / "schema-inference-receipt.json" def fake_has_resumable(root: Path) -> bool: seen_roots.append(root) return True + def validate_receipt(root: Path, receipt: Path) -> dict[str, object]: + validated_receipts.append((root, receipt)) + return {} + monkeypatch.setattr("polylogue.paths.archive_root", lambda: tmp_path) + monkeypatch.setattr( + "polylogue.maintenance.schema_inference_gate.resolve_schema_inference_receipt_reference", + lambda _root: receipt_path, + ) + monkeypatch.setattr( + "polylogue.maintenance.schema_inference_gate.validate_schema_inference_receipt", validate_receipt + ) monkeypatch.setattr( "polylogue.daemon.bulk_rebuild.has_resumable_daemon_bulk_rebuild_transaction", fake_has_resumable ) assert asyncio.run(daemon_cli._daemon_bulk_rebuild_transaction_in_flight()) is True assert seen_roots == [tmp_path] + assert validated_receipts == [(tmp_path, receipt_path)] def test_periodic_raw_materialization_convergence_suppresses_trickle_while_bulk_rebuild_in_flight( diff --git a/tests/unit/devtools/test_affordance_usage.py b/tests/unit/devtools/test_affordance_usage.py index 2a434f9d6d..c06c18c531 100644 --- a/tests/unit/devtools/test_affordance_usage.py +++ b/tests/unit/devtools/test_affordance_usage.py @@ -2,7 +2,10 @@ import csv import json +import os +import shutil import sqlite3 +import stat from contextlib import AbstractContextManager from pathlib import Path from typing import Any @@ -12,6 +15,7 @@ from devtools import affordance_usage from polylogue.cli.click_app import cli from polylogue.cli.command_inventory import iter_command_paths +from polylogue.storage.sqlite.connection_profile import open_readonly_connection from tests.infra.mcp import EXPECTED_TOOL_NAMES @@ -99,6 +103,16 @@ def _make_index_db(root: Path) -> Path: ('s1', 'm1', 'tool_use', 'functions.exec_command', 't9', NULL, 'polylogue read session:s1 --view summary', '', '', NULL, NULL); """ ) + conn.execute("CREATE VIRTUAL TABLE messages_fts USING fts5(text)") + conn.execute( + """ + INSERT INTO messages_fts(rowid, text) + SELECT rowid, lower( + coalesce(tool_command, '') || ' ' || coalesce(tool_path, '') || ' ' || coalesce(tool_input, '') + ) + FROM blocks + """ + ) conn.commit() finally: conn.close() @@ -123,7 +137,11 @@ def test_affordance_usage_report_and_files(tmp_path: Path) -> None: report = affordance_usage.build_report(args) assert report["archive_root"] == str(archive_root.resolve()) + assert report["evidence_root"] == str(archive_root.resolve()) + assert report["index_db"] == str((archive_root / "index.db").resolve()) assert report["index_schema_version"] == 18 + assert report["snapshot_identity"]["stable"] is True + assert report["snapshot_identity"]["size"] == (archive_root / "index.db").stat().st_size families = {row["family"]: row for row in report["family_counts"]} assert families["context7"]["actions"] == 2 assert families["context7"]["errors"] == 1 @@ -157,6 +175,9 @@ def test_affordance_usage_report_and_files(tmp_path: Path) -> None: assert written_report["family_counts"] == report["family_counts"] written_summary = json.loads((out_dir / "summary.json").read_text(encoding="utf-8")) assert written_summary["artifact"] == "agent-affordance-usage" + assert written_summary["evidence_root"] == report["evidence_root"] + assert written_summary["index_db"] == report["index_db"] + assert written_summary["snapshot_identity"] == report["snapshot_identity"] assert written_summary["index_schema_version"] == report["index_schema_version"] assert written_summary["proof_report"]["top_families"] == report["summary"]["top_families"] assert written_summary["proof_report"]["surface_inventory_summary"] == report["surface_inventory_summary"] @@ -166,11 +187,694 @@ def test_affordance_usage_report_and_files(tmp_path: Path) -> None: with (out_dir / "surface-inventory.csv").open(encoding="utf-8", newline="") as handle: inventory_rows = list(csv.DictReader(handle)) assert len(inventory_rows) == len(EXPECTED_TOOL_NAMES) + len(command_paths) - assert "recent" in (out_dir / "README.md").read_text(encoding="utf-8").lower() - assert "surface inventory" in (out_dir / "README.md").read_text(encoding="utf-8").lower() + readme = (out_dir / "README.md").read_text(encoding="utf-8") + assert "recent" in readme.lower() + assert "surface inventory" in readme.lower() + assert f"Evidence index: `{report['index_db']}`" in readme + assert f"Evidence snapshot SHA-256: `{report['snapshot_identity']['sha256']}`" in readme assert "`summary.json`" in (out_dir / "README.md").read_text(encoding="utf-8") +def test_affordance_usage_selected_external_index_is_the_report_evidence_source(tmp_path: Path) -> None: + configured_root = tmp_path / "configured" + selected_root = tmp_path / "selected" + _make_index_db(configured_root) + selected_db = _make_index_db(selected_root) + out_dir = tmp_path / "out" + + report = affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=configured_root, + out_dir=out_dir, + days=36500, + family=("serena",), + detail_pattern=(), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + summary = json.loads((out_dir / "summary.json").read_text(encoding="utf-8")) + assert report["index_db"] == str(selected_db.resolve()) + assert report["index_db"] != str((configured_root / "index.db").resolve()) + assert report["evidence_root"] == str(selected_root.resolve()) + snapshot_identity = report["snapshot_identity"] + assert snapshot_identity["before"]["path"] == str(selected_db.resolve()) + assert snapshot_identity["after"]["path"] == str(selected_db.resolve()) + assert snapshot_identity["before"]["index_db"] == str(selected_db.resolve()) + assert snapshot_identity["after"]["index_db"] == str(selected_db.resolve()) + assert snapshot_identity["before"]["sha256"] == snapshot_identity["after"]["sha256"] + assert snapshot_identity["size"] == selected_db.stat().st_size + assert summary["index_db"] == str(selected_db.resolve()) + assert summary["evidence_root"] == str(selected_root.resolve()) + assert summary["snapshot_identity"] == snapshot_identity + + +def test_affordance_usage_selected_sibling_index_bypasses_archive_store_fast_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + archive_root = tmp_path / "archive" + configured_db = _make_index_db(archive_root) + selected_db = archive_root / "candidate.db" + shutil.copy2(configured_db, selected_db) + calls = 0 + + class DivergentArchive(AbstractContextManager["DivergentArchive"]): + def __enter__(self) -> DivergentArchive: + return self + + def __exit__(self, *exc: object) -> None: + return None + + def list_tool_action_evidence_count_rows(self, *args: object, **kwargs: object) -> list[dict[str, object]]: + nonlocal calls + del args, kwargs + calls += 1 + return [ + { + "source_name": "wrong-index", + "origin": "codex-session", + "normalized_tool_name": "codebase-memory/command-detail", + "action_kind": "shell", + "evidence_kind": "command_detail", + "matched_by": "detail", + "call_count": 999, + "session_count": 1, + "error_count": 0, + "nonzero_exit_count": 0, + } + ] + + monkeypatch.setattr( + "polylogue.storage.sqlite.archive_tiers.archive.ArchiveStore.open_existing", + lambda _root, **_kwargs: DivergentArchive(), + ) + + report = affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=(), + detail_pattern=("codebase-memory",), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + assert calls == 0 + assert report["index_db"] == str(selected_db.resolve()) + assert {row["family"]: row["actions"] for row in report["family_counts"]}["codebase-memory"] == 2 + assert report["samples"] + + +def test_affordance_usage_rejects_product_fast_path_on_different_physical_index( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + other_db = _make_index_db(tmp_path / "other") + + class DivergentArchive(AbstractContextManager["DivergentArchive"]): + index_db_path = other_db + + def __enter__(self) -> DivergentArchive: + return self + + def __exit__(self, *exc: object) -> None: + return None + + monkeypatch.setattr( + "polylogue.storage.sqlite.archive_tiers.archive.ArchiveStore.open_existing", + lambda _root, **_kwargs: DivergentArchive(), + ) + + with pytest.raises(RuntimeError, match="different physical index"): + affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=(), + detail_pattern=("codebase-memory",), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + +def test_affordance_usage_product_fast_path_stays_pinned_across_promotion( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + archive_root = tmp_path / "archive" + archive_root.mkdir() + old_db = _make_index_db(tmp_path / "old-generation") + new_db = _make_index_db(tmp_path / "new-generation") + with sqlite3.connect(new_db) as conn: + conn.execute( + "INSERT INTO blocks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + "s1", + "m1", + "tool_use", + "functions.exec_command", + "promoted-extra", + None, + "codebase-memory extra", + "", + "", + None, + None, + ), + ) + active = archive_root / "index.db" + active.symlink_to(old_db) + + from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore + + real_open_existing = ArchiveStore.open_existing + promoted = False + opened_index_path: Path | None = None + + def promote_before_open( + root: Path, + *, + read_only: bool = True, + read_timeout: float = 5.0, + index_path: Path | None = None, + opened_main_fd: int | None = None, + ) -> ArchiveStore: + nonlocal opened_index_path, promoted + active.unlink() + active.symlink_to(new_db) + promoted = True + opened_index_path = index_path + return real_open_existing( + root, + read_only=read_only, + read_timeout=read_timeout, + index_path=index_path, + opened_main_fd=opened_main_fd, + ) + + monkeypatch.setattr(ArchiveStore, "open_existing", promote_before_open) + + report = affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=(), + detail_pattern=("codebase-memory",), + sample_limit=10, + json=True, + all_time=False, + ) + ) + + assert promoted is True + assert opened_index_path == old_db.resolve() + assert report["index_db"] == str(old_db.resolve()) + assert report["snapshot_identity"]["sha256"] == affordance_usage._snapshot_observation(old_db)["sha256"] + assert {row["family"]: row["actions"] for row in report["family_counts"]}["codebase-memory"] == 2 + + +def test_affordance_usage_product_fast_path_consumes_opened_inode_after_selected_replacement( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The product route must not reopen a selected path after its reader is pinned.""" + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + replacement_root = tmp_path / "replacement" + saved_db = tmp_path / "saved-index.db" + from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore + + real_open_existing = ArchiveStore.open_existing + replaced = False + + def replace_selected_path( + root: Path, + *, + read_only: bool = True, + read_timeout: float = 5.0, + index_path: Path | None = None, + opened_main_fd: int | None = None, + ) -> Any: + nonlocal replaced + replacement_db = _make_index_db(replacement_root) + with sqlite3.connect(replacement_db) as replacement: + replacement.execute("DELETE FROM blocks") + replacement.commit() + selected_db.rename(saved_db) + replacement_db.rename(selected_db) + try: + archive = real_open_existing( + root, + read_only=read_only, + read_timeout=read_timeout, + index_path=index_path, + opened_main_fd=opened_main_fd, + ) + finally: + selected_db.unlink() + saved_db.rename(selected_db) + replaced = True + return archive + + monkeypatch.setattr(ArchiveStore, "open_existing", replace_selected_path) + report = affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=(), + detail_pattern=("codebase-memory",), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + assert replaced is True + assert {row["family"]: row["actions"] for row in report["family_counts"]}["codebase-memory"] == 2 + assert report["snapshot_identity"]["stable"] is True + + +def test_affordance_usage_marks_selected_index_snapshot_unstable_after_change( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + writer = sqlite3.connect(selected_db) + assert writer.execute("PRAGMA journal_mode = WAL").fetchone() == ("wal",) + writer.execute("PRAGMA wal_autocheckpoint = 0") + real_observation = affordance_usage._snapshot_observation + calls = 0 + + def observe_with_change( + path: Path, + *, + opened_main_fd: int | None = None, + opened_sidecar_fds: dict[str, int] | None = None, + ) -> dict[str, object]: + nonlocal calls + calls += 1 + if calls == 2: + writer.execute("INSERT INTO sessions VALUES ('concurrent', 'codex-session', 'change', 1)") + writer.commit() + return real_observation( + path, + opened_main_fd=opened_main_fd, + opened_sidecar_fds=opened_sidecar_fds, + ) + + monkeypatch.setattr(affordance_usage, "_snapshot_observation", observe_with_change) + try: + report = affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=("serena",), + detail_pattern=(), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + finally: + writer.close() + + identity = report["snapshot_identity"] + assert identity["stable"] is False + assert identity["before"]["sha256"] != identity["after"]["sha256"] + assert identity["file_set_stable"] is False + assert identity["no_concurrent_commits"] is False + + +def test_affordance_usage_rejects_unlinked_selected_index_as_incomplete( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An open SQLite handle does not make an unlinked evidence path citable.""" + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + real_observation = affordance_usage._snapshot_observation + unlinked = False + + def unlink_before_observation( + path: Path, + *, + opened_main_fd: int | None = None, + opened_sidecar_fds: dict[str, int] | None = None, + ) -> dict[str, object]: + nonlocal unlinked + if not unlinked: + path.unlink() + unlinked = True + return real_observation( + path, + opened_main_fd=opened_main_fd, + opened_sidecar_fds=opened_sidecar_fds, + ) + + monkeypatch.setattr(affordance_usage, "_snapshot_observation", unlink_before_observation) + report = affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=("serena",), + detail_pattern=("codebase-memory",), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + identity = report["snapshot_identity"] + assert report["index_schema_version"] == 18 + assert identity["before"]["present"] is False + assert identity["after"]["present"] is False + assert identity["before"]["observation_complete"] is False + assert identity["after"]["observation_complete"] is False + assert identity["before"]["files"][0]["sha256"] + assert identity["stable"] is False + + +def test_affordance_usage_rejects_selected_index_replacement_after_reader_open( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + replacement_root = tmp_path / "replacement" + real_open = open_readonly_connection + opened_readers = 0 + + def replace_after_reader_open(path: Path, *, opened_main_fd: int | None = None) -> sqlite3.Connection: + nonlocal opened_readers + connection = real_open(path, opened_main_fd=opened_main_fd) + opened_readers += 1 + if opened_readers == 2: + replacement_db = _make_index_db(replacement_root) + selected_db.unlink() + replacement_db.replace(selected_db) + return connection + + monkeypatch.setattr(affordance_usage, "open_readonly_connection", replace_after_reader_open) + + with pytest.raises(RuntimeError, match="selected index path was replaced"): + affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=("serena",), + detail_pattern=(), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + +def test_affordance_usage_reader_stays_on_opened_inode_across_path_replacement_and_restoration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The production reader must use the inode opened before the pathname mutation.""" + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + replacement_root = tmp_path / "replacement" + original_path = tmp_path / "original-index.db" + real_open = open_readonly_connection + swapped = False + + def replace_before_reader_open(path: Path, *, opened_main_fd: int | None = None) -> sqlite3.Connection: + nonlocal swapped + if not swapped: + replacement_db = _make_index_db(replacement_root) + with sqlite3.connect(replacement_db) as replacement: + replacement.execute("DELETE FROM blocks") + replacement.commit() + selected_db.rename(original_path) + replacement_db.rename(selected_db) + connection = real_open(path, opened_main_fd=opened_main_fd) + selected_db.rename(replacement_db) + original_path.rename(selected_db) + swapped = True + return connection + return real_open(path, opened_main_fd=opened_main_fd) + + monkeypatch.setattr(affordance_usage, "open_readonly_connection", replace_before_reader_open) + report = affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=("serena",), + detail_pattern=("codebase-memory",), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + assert swapped is True + assert {row["family"]: row["actions"] for row in report["family_counts"]}["codebase-memory"] == 2 + assert report["snapshot_identity"]["stable"] is True + + +def test_affordance_usage_rejects_replaced_wal_sidecar_and_accepts_restoration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The sidecar hash must remain tied to the object SQLite opened, including restoration.""" + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + writer = sqlite3.connect(selected_db) + assert writer.execute("PRAGMA journal_mode = WAL").fetchone() == ("wal",) + writer.execute("PRAGMA wal_autocheckpoint = 0") + writer.execute("INSERT INTO sessions VALUES ('wal-row', 'codex-session', 'WAL', 1)") + writer.commit() + wal_path = Path(f"{selected_db}-wal") + saved_wal = tmp_path / "saved-index.wal" + replacement_wal = tmp_path / "replacement-index.wal" + real_open = open_readonly_connection + swapped = False + + def replace_wal_after_reader_open(path: Path, *, opened_main_fd: int | None = None) -> sqlite3.Connection: + nonlocal swapped + connection = real_open(path, opened_main_fd=opened_main_fd) + if not swapped: + wal_path.rename(saved_wal) + replacement_wal.write_bytes(b"replacement sidecar") + replacement_wal.rename(wal_path) + swapped = True + return connection + + monkeypatch.setattr(affordance_usage, "open_readonly_connection", replace_wal_after_reader_open) + with pytest.raises(RuntimeError, match="sidecar"): + affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=("serena",), + detail_pattern=(), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + wal_path.unlink() + saved_wal.rename(wal_path) + monkeypatch.setattr(affordance_usage, "open_readonly_connection", real_open) + report = affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=("serena",), + detail_pattern=(), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + writer.close() + assert report["snapshot_identity"]["stable"] is True + + +def test_affordance_usage_rejects_symlinked_wal_sidecar(tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + wal_path = Path(f"{selected_db}-wal") + wal_target = tmp_path / "wal-target" + wal_target.write_bytes(b"unsafe sidecar") + wal_path.symlink_to(wal_target) + + with pytest.raises(RuntimeError, match="sidecar"): + affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=("serena",), + detail_pattern=(), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + +def test_affordance_usage_snapshot_includes_a_quiescent_wal_file_set(tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + writer = sqlite3.connect(selected_db) + try: + assert writer.execute("PRAGMA journal_mode = WAL").fetchone() == ("wal",) + writer.execute("PRAGMA wal_autocheckpoint = 0") + writer.execute("INSERT INTO sessions VALUES ('wal-row', 'codex-session', 'WAL', 1)") + writer.commit() + assert Path(f"{selected_db}-wal").is_file() + + report = affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=("serena",), + detail_pattern=(), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + finally: + writer.close() + + identity = report["snapshot_identity"] + assert identity["stable"] is True + before_files = {Path(row["path"]).name: row for row in identity["before"]["files"]} + assert before_files["index.db-wal"]["present"] is True + + +def test_affordance_usage_captures_reader_created_sqlite_sidecars( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """WAL, SHM, and journal names created after reader open enter the authority set.""" + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + real_snapshot = affordance_usage._snapshot_observation + snapshot_calls = 0 + + def create_sidecars_before_after_snapshot( + path: Path, + *, + opened_main_fd: int | None = None, + opened_sidecar_fds: dict[str, int] | None = None, + ) -> dict[str, object]: + nonlocal snapshot_calls + snapshot_calls += 1 + if snapshot_calls == 2: + for suffix in ("-wal", "-shm", "-journal"): + Path(f"{path}{suffix}").write_bytes(b"created after reader open") + return real_snapshot( + path, + opened_main_fd=opened_main_fd, + opened_sidecar_fds=opened_sidecar_fds, + ) + + monkeypatch.setattr(affordance_usage, "_snapshot_observation", create_sidecars_before_after_snapshot) + monkeypatch.setattr(affordance_usage, "_data_version", lambda _connection: 1) + report = affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=("serena",), + detail_pattern=(), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + before_files = {Path(row["path"]).name: row for row in report["snapshot_identity"]["before"]["files"]} + after_files = {Path(row["path"]).name: row for row in report["snapshot_identity"]["after"]["files"]} + assert all(not before_files[f"index.db{suffix}"]["present"] for suffix in ("-wal", "-shm", "-journal")) + assert all(after_files[f"index.db{suffix}"]["present"] for suffix in ("-wal", "-shm", "-journal")) + assert report["snapshot_identity"]["stable"] is False + + +@pytest.mark.parametrize( + ("target", "kind"), + [ + ("main", "directory"), + ("main", "fifo"), + ("sidecar", "directory"), + ("sidecar", "fifo"), + ("main", "device"), + ("sidecar", "device"), + ], +) +def test_affordance_usage_rejects_nonregular_main_and_sidecar_objects( + tmp_path: Path, + target: str, + kind: str, +) -> None: + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + object_path = selected_db if target == "main" else Path(f"{selected_db}-wal") + if target == "main": + selected_db.unlink() + if kind == "directory": + object_path.mkdir() + elif kind == "fifo": + os.mkfifo(object_path) + else: + try: + os.mknod(object_path, stat.S_IFCHR | 0o600, os.makedev(1, 3)) + except PermissionError: + pytest.skip("device nodes are unavailable in this test environment") + + with pytest.raises(RuntimeError, match="regular|safely|sidecar"): + affordance_usage.build_report( + affordance_usage.AffordanceUsageArgs( + archive_root=archive_root, + out_dir=None, + days=36500, + family=("serena",), + detail_pattern=(), + sample_limit=10, + json=True, + all_time=False, + index_db=selected_db, + ) + ) + + def test_affordance_usage_rejects_nonpositive_recent_window(tmp_path: Path) -> None: archive_root = tmp_path / "archive" _make_index_db(archive_root) @@ -196,6 +900,8 @@ def test_affordance_usage_rejects_nonpositive_recent_window(tmp_path: Path) -> N def test_affordance_usage_can_match_shell_command_details(tmp_path: Path) -> None: archive_root = tmp_path / "archive" _make_index_db(archive_root) + selected_db = archive_root / "selected-index.db" + shutil.copy2(archive_root / "index.db", selected_db) args = affordance_usage.AffordanceUsageArgs( archive_root=archive_root, out_dir=None, @@ -205,6 +911,7 @@ def test_affordance_usage_can_match_shell_command_details(tmp_path: Path) -> Non sample_limit=10, json=True, all_time=False, + index_db=selected_db, ) report = affordance_usage.build_report(args) @@ -222,6 +929,8 @@ def test_affordance_usage_can_match_shell_command_details(tmp_path: Path) -> Non def test_affordance_usage_treats_like_wildcards_as_literals(tmp_path: Path) -> None: archive_root = tmp_path / "archive" _make_index_db(archive_root) + selected_db = archive_root / "selected-index.db" + shutil.copy2(archive_root / "index.db", selected_db) args = affordance_usage.AffordanceUsageArgs( archive_root=archive_root, out_dir=None, @@ -231,6 +940,7 @@ def test_affordance_usage_treats_like_wildcards_as_literals(tmp_path: Path) -> N sample_limit=10, json=True, all_time=False, + index_db=selected_db, ) report = affordance_usage.build_report(args) @@ -252,6 +962,8 @@ def test_affordance_usage_detail_fast_path_splits_mixed_known_families( calls: list[tuple[str, ...]] = [] class FakeArchive(AbstractContextManager["FakeArchive"]): + index_db_path = archive_root / "index.db" + def __enter__(self) -> FakeArchive: return self @@ -301,7 +1013,7 @@ def list_tool_action_evidence_count_rows( monkeypatch.setattr( "polylogue.storage.sqlite.archive_tiers.archive.ArchiveStore.open_existing", - lambda _root: FakeArchive(), + lambda _root, **_kwargs: FakeArchive(), ) args = affordance_usage.AffordanceUsageArgs( archive_root=archive_root, diff --git a/tests/unit/devtools/test_lineage_validation.py b/tests/unit/devtools/test_lineage_validation.py index dbebd74b25..5617a806e7 100644 --- a/tests/unit/devtools/test_lineage_validation.py +++ b/tests/unit/devtools/test_lineage_validation.py @@ -15,6 +15,7 @@ from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.archive_tiers.write import write_parsed_session_to_archive +from polylogue.storage.sqlite.connection_profile import open_readonly_connection from tests.infra.frozen_clock import FrozenClock @@ -182,6 +183,7 @@ def _args( out_dir: Path | None = None, *, index_db: Path | None = None, + sample_unresolved: int = 10, ) -> lineage_validation.LineageValidationArgs: return lineage_validation.LineageValidationArgs( archive_root=archive_root, @@ -190,6 +192,7 @@ def _args( max_sample_stored_messages=500, json=True, index_db=index_db, + sample_unresolved=sample_unresolved, ) @@ -308,6 +311,8 @@ def test_lineage_validation_samples_distinct_unresolved_edges_without_multiplyin resolved_dst_session_id, method, evidence_json, branch_point_message_id, inheritance) VALUES ('orphan', 'codex-session', 'missing-parent', 'subagent', NULL, NULL, 'parent-tool-use-id', '{}', NULL, 'spawned-fresh') + ,('child', 'codex-session', 'alternate-parent', 'continuation', NULL, + NULL, 'parser-parent', '{}', NULL, 'spawned-fresh') """ ) conn.commit() @@ -316,13 +321,69 @@ def test_lineage_validation_samples_distinct_unresolved_edges_without_multiplyin sample = report["lineage"]["topology"]["unresolved_read_sample"] assert sample["safe"] is True + assert sample["unresolved_count"] == 3 + assert sample["effective_unresolved_count"] == 2 assert sample["sampled"] == 2 + assert {row["session_id"] for row in sample["rows"]} == {"orphan"} assert {row["link_type"] for row in sample["rows"]} == {"continuation", "subagent"} assert {row["stored_messages"] for row in sample["rows"]} == {1} assert {row["served_messages"] for row in sample["rows"]} == {1} assert report["verdict"]["external_counts_citable"] is True +def test_lineage_validation_treats_only_alternate_unresolved_edges_as_not_applicable(tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + db = _make_index_db(archive_root) + with sqlite3.connect(db) as conn: + conn.execute( + """ + INSERT INTO session_links + (src_session_id, dst_origin, dst_native_id, link_type, status, + resolved_dst_session_id, method, evidence_json, branch_point_message_id, inheritance) + VALUES ('child', 'codex-session', 'alternate-parent', 'continuation', NULL, + NULL, 'parser-parent', '{}', NULL, 'spawned-fresh') + """ + ) + conn.commit() + + report = lineage_validation.build_report(_args(archive_root)) + + topology = report["lineage"]["topology"] + sample = topology["unresolved_read_sample"] + assert topology["unresolved_count"] == 1 + assert topology["effective_unresolved_count"] == 0 + assert sample["unresolved_count"] == 1 + assert sample["effective_unresolved_count"] == 0 + assert sample["sampled"] == 0 + assert sample["status"] == "not_applicable" + assert sample["safe"] is True + assert report["verdict"]["external_counts_citable"] is True + + +def test_lineage_validation_samples_unresolved_edge_when_resolved_edge_does_not_compose(tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + db = _make_index_db(archive_root) + with sqlite3.connect(db) as conn: + conn.execute( + """ + INSERT INTO session_links + (src_session_id, dst_origin, dst_native_id, link_type, status, + resolved_dst_session_id, method, evidence_json, branch_point_message_id, inheritance) + VALUES ('fresh', 'claude-code-session', 'missing-parent', 'subagent', NULL, + NULL, 'parent-tool-use-id', '{}', NULL, 'spawned-fresh') + """ + ) + conn.commit() + + report = lineage_validation.build_report(_args(archive_root)) + + sample = report["lineage"]["topology"]["unresolved_read_sample"] + assert sample["effective_unresolved_count"] == 1 + assert sample["sampled"] == 1 + assert sample["status"] == "safe" + assert sample["rows"][0]["session_id"] == "fresh" + + def test_lineage_validation_proves_writer_candidate_and_snapshot_identity(tmp_path: Path) -> None: archive_root = tmp_path / "candidate" db = _make_writer_candidate(archive_root) @@ -339,7 +400,12 @@ def test_lineage_validation_proves_writer_candidate_and_snapshot_identity(tmp_pa assert topology["unresolved_read_sample"]["sampled"] == 1 assert report["index_db"] == str(db.resolve()) assert report["snapshot_identity"]["stable"] is True - assert report["snapshot_identity"]["before"]["sha256"] == report["snapshot_identity"]["after"]["sha256"] + snapshot_identity = report["snapshot_identity"] + assert snapshot_identity["before"]["index_db"] == str(db.resolve()) + assert snapshot_identity["after"]["index_db"] == str(db.resolve()) + assert snapshot_identity["before"]["path"] == str(db.resolve()) + assert snapshot_identity["after"]["path"] == str(db.resolve()) + assert snapshot_identity["before"]["sha256"] == snapshot_identity["after"]["sha256"] def test_lineage_validation_rejects_unobserved_unresolved_reader_sample(tmp_path: Path) -> None: @@ -360,7 +426,27 @@ def test_lineage_validation_rejects_unobserved_unresolved_reader_sample(tmp_path assert sample["status"] == "not_observed" assert sample["safe"] is False assert report["verdict"]["external_counts_citable"] is False - assert "1 unresolved-parent links were not exercised through the reader" in report["verdict"]["reasons"] + assert "1 effective unresolved-parent link was not exercised through the reader" in report["verdict"]["reasons"] + + +def test_lineage_validation_uses_plural_unresolved_reader_reason_for_multiple_links(tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + db = _make_index_db(archive_root, with_unresolved=True) + with sqlite3.connect(db) as conn: + conn.execute( + """ + INSERT INTO session_links + (src_session_id, dst_origin, dst_native_id, link_type, status, + resolved_dst_session_id, method, evidence_json, branch_point_message_id, inheritance) + VALUES ('orphan', 'codex-session', 'another-missing-parent', 'continuation', NULL, + NULL, 'parser-parent', '{}', NULL, 'spawned-fresh') + """ + ) + conn.commit() + + report = lineage_validation.build_report(_args(archive_root, sample_unresolved=0)) + + assert "2 effective unresolved-parent links were not exercised through the reader" in report["verdict"]["reasons"] @pytest.mark.frozen_clock_modules("devtools.lineage_validation") @@ -415,13 +501,22 @@ def test_lineage_validation_rejects_commit_between_reader_snapshot_and_file_hash original_snapshot_identity = lineage_validation._snapshot_identity snapshot_calls = 0 - def commit_before_first_file_hash(index_db: Path) -> dict[str, object]: + def commit_before_first_file_hash( + index_db: Path, + *, + opened_main_fd: int | None = None, + opened_sidecar_fds: dict[str, int] | None = None, + ) -> dict[str, object]: nonlocal snapshot_calls if snapshot_calls == 0: writer.execute("UPDATE session_links SET method = 'concurrent' WHERE src_session_id = 'child'") writer.commit() snapshot_calls += 1 - return original_snapshot_identity(index_db) + return original_snapshot_identity( + index_db, + opened_main_fd=opened_main_fd, + opened_sidecar_fds=opened_sidecar_fds, + ) monkeypatch.setattr(lineage_validation, "_snapshot_identity", commit_before_first_file_hash) try: @@ -438,6 +533,148 @@ def commit_before_first_file_hash(index_db: Path) -> dict[str, object]: assert "index received a concurrent commit during the read-only census" in report["verdict"]["reasons"] +def test_lineage_validation_rejects_unlinked_selected_index_as_incomplete( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An open SQLite handle does not make an unlinked evidence path citable.""" + archive_root = tmp_path / "archive" + db = _make_index_db(archive_root) + original_snapshot_identity = lineage_validation._snapshot_identity + unlinked = False + + def unlink_before_observation( + index_db: Path, + *, + opened_main_fd: int | None = None, + opened_sidecar_fds: dict[str, int] | None = None, + ) -> dict[str, object]: + nonlocal unlinked + if not unlinked: + index_db.unlink() + unlinked = True + return original_snapshot_identity( + index_db, + opened_main_fd=opened_main_fd, + opened_sidecar_fds=opened_sidecar_fds, + ) + + monkeypatch.setattr(lineage_validation, "_snapshot_identity", unlink_before_observation) + report = lineage_validation.build_report(_args(archive_root)) + + identity = report["snapshot_identity"] + assert report["index_db"] == str(db.resolve()) + assert identity["before"]["present"] is False + assert identity["after"]["present"] is False + assert identity["before"]["observation_complete"] is False + assert identity["after"]["observation_complete"] is False + assert identity["observation_complete"] is False + assert identity["stable"] is False + assert report["verdict"]["external_counts_citable"] is False + assert "index file-set observation was incomplete" in report["verdict"]["reasons"] + + +def test_lineage_validation_rejects_selected_index_replacement_after_reader_open( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + replacement_root = tmp_path / "replacement" + real_open = open_readonly_connection + opened_readers = 0 + + def replace_after_reader_open(path: Path, *, opened_main_fd: int | None = None) -> sqlite3.Connection: + nonlocal opened_readers + connection = real_open(path, opened_main_fd=opened_main_fd) + opened_readers += 1 + if opened_readers == 2: + replacement_db = _make_index_db(replacement_root) + selected_db.unlink() + replacement_db.replace(selected_db) + return connection + + monkeypatch.setattr(lineage_validation, "open_readonly_connection", replace_after_reader_open) + + with pytest.raises(RuntimeError, match="selected index path was replaced"): + lineage_validation.build_report(_args(archive_root)) + + +def test_lineage_validation_reader_stays_on_opened_inode_across_path_replacement_and_restoration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The production reader must use the inode opened before the pathname mutation.""" + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + replacement_root = tmp_path / "replacement" + original_path = tmp_path / "original-index.db" + real_open = open_readonly_connection + swapped = False + + def replace_before_reader_open(path: Path, *, opened_main_fd: int | None = None) -> sqlite3.Connection: + nonlocal swapped + if not swapped: + replacement_db = _make_index_db(replacement_root) + with sqlite3.connect(replacement_db) as replacement: + replacement.execute("DELETE FROM sessions") + replacement.execute("DELETE FROM messages") + replacement.execute("DELETE FROM blocks") + replacement.execute("DELETE FROM session_links") + replacement.execute("DELETE FROM session_profiles") + replacement.commit() + selected_db.rename(original_path) + replacement_db.rename(selected_db) + connection = real_open(path, opened_main_fd=opened_main_fd) + selected_db.rename(replacement_db) + original_path.rename(selected_db) + swapped = True + return connection + return real_open(path, opened_main_fd=opened_main_fd) + + monkeypatch.setattr(lineage_validation, "open_readonly_connection", replace_before_reader_open) + report = lineage_validation.build_report(_args(archive_root)) + + assert swapped is True + assert report["counts"]["physical_sessions"] == 3 + assert report["snapshot_identity"]["stable"] is True + + +def test_lineage_validation_captures_reader_created_sqlite_sidecars( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Lineage evidence adopts sidecars created by the second SQLite reader.""" + archive_root = tmp_path / "archive" + selected_db = _make_index_db(archive_root) + real_snapshot = lineage_validation._snapshot_identity + snapshot_calls = 0 + + def create_sidecars_before_after_snapshot( + path: Path, + *, + opened_main_fd: int | None = None, + opened_sidecar_fds: dict[str, int] | None = None, + ) -> dict[str, object]: + nonlocal snapshot_calls + snapshot_calls += 1 + if snapshot_calls == 2: + for suffix in ("-wal", "-shm", "-journal"): + Path(f"{path}{suffix}").write_bytes(b"created after reader open") + return real_snapshot( + path, + opened_main_fd=opened_main_fd, + opened_sidecar_fds=opened_sidecar_fds, + ) + + monkeypatch.setattr(lineage_validation, "_snapshot_identity", create_sidecars_before_after_snapshot) + monkeypatch.setattr(lineage_validation, "_data_version", lambda _connection: 1) + report = lineage_validation.build_report(_args(archive_root, index_db=selected_db)) + + before_files = {Path(row["path"]).name: row for row in report["snapshot_identity"]["before"]["files"]} + after_files = {Path(row["path"]).name: row for row in report["snapshot_identity"]["after"]["files"]} + assert all(not before_files[f"index.db{suffix}"]["present"] for suffix in ("-wal", "-shm", "-journal")) + assert all(after_files[f"index.db{suffix}"]["present"] for suffix in ("-wal", "-shm", "-journal")) + assert report["snapshot_identity"]["stable"] is False + + def test_lineage_validation_rejects_budget_exhaustion_as_cycle_proof(tmp_path: Path) -> None: archive_root = tmp_path / "archive" db = _make_index_db(archive_root) @@ -558,6 +795,23 @@ def test_lineage_validation_writes_demo_artifacts(tmp_path: Path) -> None: assert "external counts citable: `true`" in readme +def test_lineage_validation_artifacts_attribute_selected_index(tmp_path: Path) -> None: + configured_root = tmp_path / "configured" + candidate_root = tmp_path / "candidate" + _make_index_db(configured_root) + candidate_db = _make_index_db(candidate_root) + out_dir = tmp_path / "out" + + report = lineage_validation.build_report(_args(configured_root, out_dir, index_db=candidate_db)) + + summary = json.loads((out_dir / "summary.json").read_text(encoding="utf-8")) + readme = (out_dir / "README.md").read_text(encoding="utf-8") + assert summary["index_db"] == report["index_db"] == str(candidate_db.resolve()) + assert summary["snapshot_identity"] == report["snapshot_identity"] + assert f"Evidence index: `{candidate_db.resolve()}`" in readme + assert f"Evidence snapshot SHA-256: `{report['snapshot_identity']['sha256']}`" in readme + + def test_lineage_validation_command_registered() -> None: spec = COMMANDS["workspace lineage-validation"] assert spec.module == "devtools.lineage_validation" diff --git a/tests/unit/maintenance/test_rebuild_index_ownership.py b/tests/unit/maintenance/test_rebuild_index_ownership.py index 834ebb6b95..a56f52a34b 100644 --- a/tests/unit/maintenance/test_rebuild_index_ownership.py +++ b/tests/unit/maintenance/test_rebuild_index_ownership.py @@ -17,15 +17,21 @@ import pytest +from polylogue.archive.revision_authority import RawRevisionAuthority, RawRevisionEnvelope, RawRevisionKind +from polylogue.core.enums import Provider from polylogue.maintenance.rebuild_index import ( RebuildIndexRequest, RebuildSchemaCurrencyError, rebuild_index_from_source_sync, ) +from polylogue.sources.revision_backfill import census_historical_revision_evidence from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation from polylogue.storage.archive_readiness import probe_archive_tier from polylogue.storage.blob_store import BlobStore +from polylogue.storage.index_generation import IndexGenerationStore, RebuildLease, rebuild_source_evidence_snapshot +from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_database +from polylogue.storage.sqlite.archive_tiers.source import SOURCE_SCHEMA_VERSION from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS from tests.infra.rebuild_receipt import write_valid_rebuild_receipt @@ -37,19 +43,41 @@ def _init_empty_source(root: Path) -> None: initialize_archive_database(root / f"{tier.value}.db", tier) -def test_rebuild_rejects_source_schema_behind_runtime_before_candidate_creation(tmp_path: Path) -> None: - """A real v28 source tier must not reach the v29 rebuild package. +def _init_nonempty_source(root: Path) -> None: + initialize_active_archive_root(root) + payload = ( + b'{"type":"session_meta","payload":{"id":"owned-session"}}\n' + b'{"type":"response_item","payload":{"type":"message","role":"user",' + b'"content":[{"type":"input_text","text":"owned"}]}}\n' + ) + with ArchiveStore.open_existing(root, read_only=False) as archive: + archive.write_raw_payload( + provider=Provider.CODEX, + payload=payload, + source_path="current/owned.jsonl", + acquired_at_ms=1, + revision=RawRevisionEnvelope( + logical_source_key="codex-session:owned-session", + kind=RawRevisionKind.FULL, + source_revision="owned-revision", + acquisition_generation=0, + authority=RawRevisionAuthority.ASSERTED, + ), + ) + with sqlite3.connect(root / "source.db") as conn: + conn.execute("UPDATE raw_sessions SET baseline_raw_id = raw_id, revision_authority = 'byte_proven'") + conn.commit() + census = census_historical_revision_evidence(root) + assert census.scanned == 1 + assert census.classified_full == 1 - The test builds ordinary file-backed archive tiers, removes exactly v29's - additive objects, and supplies a valid rebuild receipt. The production - rebuild route used to accept this archive and return ``empty-source``. - """ + +def test_rebuild_rejects_source_schema_behind_runtime_before_candidate_creation(tmp_path: Path) -> None: + """A source tier behind the runtime must not reach the rebuild package.""" root = tmp_path / "archive" initialize_active_archive_root(root) with sqlite3.connect(root / "source.db") as conn: - conn.execute("DROP INDEX idx_raw_failure_disposition_receipts_disposed_at") - conn.execute("DROP TABLE raw_failure_disposition_receipts") - conn.execute("PRAGMA user_version = 28") + conn.execute(f"PRAGMA user_version = {SOURCE_SCHEMA_VERSION - 1}") receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") with pytest.raises(RebuildSchemaCurrencyError) as exc_info: @@ -63,8 +91,8 @@ def test_rebuild_rejects_source_schema_behind_runtime_before_candidate_creation( { "tier": "source", "path": str(root / "source.db"), - "actual_user_version": 28, - "expected_user_version": 29, + "actual_user_version": SOURCE_SCHEMA_VERSION - 1, + "expected_user_version": SOURCE_SCHEMA_VERSION, "status": "mismatch", } ] @@ -162,7 +190,7 @@ def test_rebuild_refuses_when_archive_location_already_owned(tmp_path: Path) -> (a different, rebuild-specific lock) racing to the same conclusion. """ root = tmp_path / "archive" - _init_empty_source(root) + _init_nonempty_source(root) receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") location = ArchiveLocation.resolve(root) owned = OwnedArchiveLocation.acquire(location, owner_id="concurrent-campaign") @@ -173,6 +201,7 @@ def test_rebuild_refuses_when_archive_location_already_owned(tmp_path: Path) -> ) # Failure happened before any generation bookkeeping was created. assert not (root / ".index-generations").exists() + assert not (root / ".index-rebuild-transactions").exists() # The rebuild lease is now deliberately acquired before the general # archive-location ownership attempt. Its released lock file may # remain as a diagnostic artifact, but no generation may be created. @@ -183,7 +212,7 @@ def test_rebuild_refuses_when_archive_location_already_owned(tmp_path: Path) -> receipt = rebuild_index_from_source_sync( RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) ) - assert receipt.status == "empty-source" + assert receipt.status == "replayed" def test_rebuild_blocks_unsafe_cursor_authority_before_generation_creation( @@ -191,8 +220,12 @@ def test_rebuild_blocks_unsafe_cursor_authority_before_generation_creation( monkeypatch: pytest.MonkeyPatch, ) -> None: root = tmp_path / "archive" - _init_empty_source(root) - cursor_payload = b"cursor-authority-fixture" + initialize_active_archive_root(root) + cursor_payload = ( + b'{"type":"session_meta","payload":{"id":"session-1"}}\n' + b'{"type":"response_item","payload":{"type":"message","role":"user",' + b'"content":[{"type":"input_text","text":"cursor authority"}]}}\n' + ) cursor_blob_hash, _ = BlobStore(root / "blob").write_from_bytes(cursor_payload) with sqlite3.connect(root / "source.db") as conn: conn.execute( @@ -207,9 +240,12 @@ def test_rebuild_blocks_unsafe_cursor_authority_before_generation_creation( (bytes.fromhex(cursor_blob_hash), len(cursor_payload)), ) conn.commit() + census = census_historical_revision_evidence(root) + assert census.scanned == 1 + assert census.classified_full == 1 monkeypatch.setattr( "polylogue.readiness.capability.raw_frontier_source_selection_block_reason", - lambda _root: "1 ingest cursor row committed past accepted raw material", + lambda _root, _materialization: "1 ingest cursor row committed past accepted raw material", ) receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") @@ -313,12 +349,16 @@ def test_rebuild_releases_ownership_lock_after_completion(tmp_path: Path) -> Non """ root = tmp_path / "archive" _init_empty_source(root) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") + tier_bytes_before = {tier.value: (root / f"{tier.value}.db").read_bytes() for tier in DURABLE_MIGRATION_TIERS} - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) + receipt = rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root)) assert receipt.status == "empty-source" + assert receipt.consumed_evidence == {} + assert receipt.generation == {} + assert not (root / ".index-rebuild-transactions").exists() + assert { + tier.value: (root / f"{tier.value}.db").read_bytes() for tier in DURABLE_MIGRATION_TIERS + } == tier_bytes_before location = ArchiveLocation.resolve(root) owned = OwnedArchiveLocation.acquire(location, owner_id="post-rebuild-probe") @@ -326,3 +366,123 @@ def test_rebuild_releases_ownership_lock_after_completion(tmp_path: Path) -> Non assert (root / ".archive-ownership.lock").exists() finally: owned.release() + + +def test_empty_source_rebuild_retains_consumed_evidence_for_resumed_request(tmp_path: Path) -> None: + root = tmp_path / "archive" + _init_empty_source(root) + receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") + store = IndexGenerationStore.for_archive_root(root) + transaction = store.create_transaction( + source_snapshot=rebuild_source_evidence_snapshot(root), + operation_id="empty-source-resume", + ) + + receipt = rebuild_index_from_source_sync( + RebuildIndexRequest( + archive_root=root, + operation_id=transaction.operation_id, + schema_inference_receipt_path=receipt_path, + ) + ) + + assert receipt.status == "empty-source" + assert receipt.consumed_evidence["receipt_path"] == str(receipt_path) + checkpoint = IndexGenerationStore.for_archive_root(root, repair_anchor=False).load_transaction( + transaction.operation_id + ) + assert checkpoint.status == "stale" + assert checkpoint.error == "rebuild source is empty; resumable transaction cannot continue" + + +def _replace_root_after_rebuild_lease( + monkeypatch: pytest.MonkeyPatch, + root: Path, + moved_root: Path, +) -> None: + real_enter = RebuildLease.__enter__ + swapped = False + + def swap_after_acquire(lease: RebuildLease) -> RebuildLease: + nonlocal swapped + entered = real_enter(lease) + if not swapped: + root.rename(moved_root) + initialize_active_archive_root(root) + swapped = True + return entered + + monkeypatch.setattr(RebuildLease, "__enter__", swap_after_acquire) + + +def test_invalid_resume_refuses_root_replacement_before_marking_transaction_stale( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "archive" + moved_root = tmp_path / "moved-archive" + _init_empty_source(root) + transaction = IndexGenerationStore.for_archive_root(root).create_transaction( + source_snapshot=rebuild_source_evidence_snapshot(root), + operation_id="invalid-resume-root-replacement", + ) + transaction_before = (root / ".index-rebuild-transactions" / f"{transaction.operation_id}.json").read_bytes() + receipt_path = tmp_path / "invalid-receipt.json" + receipt_path.write_text("{}", encoding="utf-8") + _replace_root_after_rebuild_lease(monkeypatch, root, moved_root) + + with pytest.raises(ArchiveOwnershipError, match="archive root"): + rebuild_index_from_source_sync( + RebuildIndexRequest( + archive_root=root, + operation_id=transaction.operation_id, + schema_inference_receipt_path=receipt_path, + ) + ) + + assert ( + moved_root / ".index-rebuild-transactions" / f"{transaction.operation_id}.json" + ).read_bytes() == transaction_before + + +def test_empty_source_resume_refuses_root_replacement_before_retiring_transaction( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "archive" + moved_root = tmp_path / "moved-archive" + _init_empty_source(root) + receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") + transaction = IndexGenerationStore.for_archive_root(root).create_transaction( + source_snapshot=rebuild_source_evidence_snapshot(root), + operation_id="empty-resume-root-replacement", + ) + transaction_before = (root / ".index-rebuild-transactions" / f"{transaction.operation_id}.json").read_bytes() + _replace_root_after_rebuild_lease(monkeypatch, root, moved_root) + + with pytest.raises(ArchiveOwnershipError, match="archive root"): + rebuild_index_from_source_sync( + RebuildIndexRequest( + archive_root=root, + operation_id=transaction.operation_id, + schema_inference_receipt_path=receipt_path, + ) + ) + + assert ( + moved_root / ".index-rebuild-transactions" / f"{transaction.operation_id}.json" + ).read_bytes() == transaction_before + + +def test_empty_source_rebuild_does_not_bypass_archive_ownership( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "archive" + _init_empty_source(root) + + def refuse_ownership(*args: object, **kwargs: object) -> OwnedArchiveLocation: + raise ArchiveOwnershipError("empty-source ownership probe") + + monkeypatch.setattr(OwnedArchiveLocation, "acquire", refuse_ownership) + + with pytest.raises(ArchiveOwnershipError, match="empty-source ownership probe"): + rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root)) diff --git a/tests/unit/maintenance/test_rebuild_index_provenance_gate.py b/tests/unit/maintenance/test_rebuild_index_provenance_gate.py index b1515128fd..b76780d6d3 100644 --- a/tests/unit/maintenance/test_rebuild_index_provenance_gate.py +++ b/tests/unit/maintenance/test_rebuild_index_provenance_gate.py @@ -130,6 +130,19 @@ def test_missing_receipt_fails_before_lease_and_candidate_mutation(tmp_path: Pat assert not (root / ".index-generations").exists() +def test_nonempty_source_still_requires_schema_inference_receipt_after_ownership( + tmp_path: Path, +) -> None: + root = tmp_path / "archive" + _seed(root, count=1) + + with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): + rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root)) + + assert not (root / ".index-generations").exists() + assert not list((root / ".index-rebuild-transactions").glob("*.json")) + + def test_receipt_reference_policy_fails_before_candidate_mutation(tmp_path: Path) -> None: root = tmp_path / "archive" _seed(root, count=1) @@ -234,9 +247,9 @@ def counted_inventory(roots: object) -> object: original_select = rebuild_index_module.select_rebuild_raw_ids inventory_calls_before_refresh: int | None = None - def select_then_touch(request: RebuildIndexRequest) -> tuple[int, list[str], int]: + def select_then_touch(request: RebuildIndexRequest, **kwargs: object) -> tuple[int, list[str], int]: nonlocal inventory_calls_before_refresh - selected = original_select(request) + selected = original_select(request, **kwargs) # type: ignore[arg-type] stat = external_path.stat() os.utime(external_path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) inventory_calls_before_refresh = full_inventory_calls @@ -448,6 +461,39 @@ def test_invalid_resume_marks_transaction_stale_without_repairing_active_anchor( assert checkpoint.status == "stale" +def test_invalid_empty_source_resume_cannot_return_success_or_remain_resumable(tmp_path: Path) -> None: + """Initial resume admission still retires an operation when source is empty. + + Anti-vacuity: the production offline rebuild route is given a real + ``IndexRebuildTransaction`` and an invalid explicit receipt. The old + early ``empty-source`` return leaves that transaction resumable and makes + this assertion fail. + """ + root = tmp_path / "archive" + initialize_active_archive_root(root) + store = IndexGenerationStore.for_archive_root(root) + transaction = store.create_transaction( + source_snapshot=rebuild_source_evidence_snapshot(root), + operation_id="invalid-empty-source-resume", + ) + receipt_path = tmp_path / "invalid-receipt.json" + receipt_path.write_text("{}", encoding="utf-8") + + with pytest.raises(rebuild_index_module.RebuildProvenanceError, match="schema-inference preflight gate failed"): + rebuild_index_from_source_sync( + RebuildIndexRequest( + archive_root=root, + schema_inference_receipt_path=receipt_path, + operation_id=transaction.operation_id, + ) + ) + + checkpoint = IndexGenerationStore.for_archive_root(root, repair_anchor=False).load_transaction( + transaction.operation_id + ) + assert checkpoint.status == "stale" + + def test_resume_revalidates_external_mapping_before_more_replay(tmp_path: Path) -> None: root = tmp_path / "archive" _seed(root, count=2) diff --git a/tests/unit/storage/test_archive_identity.py b/tests/unit/storage/test_archive_identity.py index 695dd5ff92..720498f5a5 100644 --- a/tests/unit/storage/test_archive_identity.py +++ b/tests/unit/storage/test_archive_identity.py @@ -1,10 +1,12 @@ from __future__ import annotations +import fcntl import os from pathlib import Path import pytest +import polylogue.storage.archive_identity as archive_identity from polylogue.storage.archive_identity import ( ArchiveIdentity, ArchiveIdentityConflictError, @@ -80,6 +82,46 @@ def test_resolve_active_index_path_rejects_malformed_pointer(tmp_path: Path, poi resolve_active_index_path(root) +def test_archive_location_rejects_undecodable_active_pointer(tmp_path: Path) -> None: + root = tmp_path / "archive" + root.mkdir() + (root / ".index-active-pointer").write_bytes(b"\xff") + + with pytest.raises(ArchiveLocationError, match="cannot read active index pointer"): + ArchiveLocation.resolve(root) + + +def test_lock_holder_pid_ignores_undecodable_owner_metadata(tmp_path: Path) -> None: + lock_path = tmp_path / ".archive-ownership.lock" + lock_path.write_bytes(b"\xff") + + assert archive_identity._lock_holder_pid(lock_path) is None + + +def test_ownership_metadata_write_failure_closes_lock_descriptor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "archive" + root.mkdir() + closed: list[int] = [] + real_close = os.close + + def record_close(descriptor: int) -> None: + closed.append(descriptor) + real_close(descriptor) + + monkeypatch.setattr("polylogue.storage.archive_identity.os.close", record_close) + monkeypatch.setattr( + "polylogue.storage.archive_identity.os.fsync", + lambda _descriptor: (_ for _ in ()).throw(OSError("disk full")), + ) + + with pytest.raises(ArchiveOwnershipError, match="cannot record archive ownership lock owner"): + OwnedArchiveLocation.acquire(ArchiveLocation.resolve(root)) + + assert closed + + def test_split_roots_sharing_durable_tiers_reject_distinct_indexes_before_mutation(tmp_path: Path) -> None: configured = tmp_path / "configured" active = tmp_path / "active" @@ -161,6 +203,35 @@ def test_missing_index_cannot_bypass_split_root_preflight(tmp_path: Path, missin assert not (missing_root / "index.db").exists() +def test_owned_location_rejects_hardlinked_lock_before_truncate(tmp_path: Path) -> None: + root = tmp_path / "archive" + root.mkdir() + external_lock = tmp_path / "external-lock" + external_lock.write_bytes(b"preserve me") + lock_path = root / ".archive-ownership.lock" + lock_path.hardlink_to(external_lock) + + with pytest.raises(ArchiveOwnershipError, match="link count"): + OwnedArchiveLocation.acquire(ArchiveLocation.resolve(root)) + + assert external_lock.read_bytes() == b"preserve me" + assert lock_path.read_bytes() == b"preserve me" + + +@pytest.mark.parametrize("object_kind", ["directory", "fifo"]) +def test_owned_location_rejects_nonregular_lock_without_blocking(tmp_path: Path, object_kind: str) -> None: + root = tmp_path / "archive" + root.mkdir() + lock_path = root / ".archive-ownership.lock" + if object_kind == "directory": + lock_path.mkdir() + else: + os.mkfifo(lock_path) + + with pytest.raises(ArchiveOwnershipError, match="lock"): + OwnedArchiveLocation.acquire(ArchiveLocation.resolve(root)) + + def test_owned_location_rejects_concurrent_acquire_before_any_sqlite_file_exists(tmp_path: Path) -> None: root = tmp_path / "archive" root.mkdir() @@ -180,6 +251,69 @@ def test_owned_location_rejects_concurrent_acquire_before_any_sqlite_file_exists assert not (root / name).exists(), f"{name} must not exist: ownership must fail before SQLite opens" +def test_owned_location_rejects_archive_root_replacement_during_acquisition( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The lease must retain the root inode it validated, not a replacement pathname.""" + root = tmp_path / "archive" + moved_root = tmp_path / "moved-archive" + root.mkdir() + location = ArchiveLocation.resolve(root) + real_open = os.open + swapped = False + + def swap_after_root_open( + file: os.PathLike[str] | str, + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + nonlocal swapped + descriptor = real_open(file, flags, mode, dir_fd=dir_fd) + if not swapped and dir_fd is None and Path(file) == root and flags & getattr(os, "O_DIRECTORY", 0): + root.rename(moved_root) + root.mkdir() + swapped = True + return descriptor + + monkeypatch.setattr("polylogue.storage.archive_identity.os.open", swap_after_root_open) + with pytest.raises(ArchiveOwnershipError, match="archive root changed"): + OwnedArchiveLocation.acquire(location) + + assert swapped is True + assert not (root / ".archive-ownership.lock").exists() + assert not (moved_root / ".archive-ownership.lock").exists() + + +def test_owned_location_rejects_lock_path_rebound_after_flock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The acquired flock must still be reachable at the canonical lock path.""" + root = tmp_path / "archive" + root.mkdir() + lock_path = root / ".archive-ownership.lock" + displaced_lock = root / ".archive-ownership.displaced" + replacement_lock = root / ".archive-ownership.replacement" + replacement_lock.write_text("foreign owner", encoding="utf-8") + real_flock = fcntl.flock + rebound = False + + def rebind_after_lock(fd: int, operation: int) -> None: + nonlocal rebound + real_flock(fd, operation) + if not rebound and operation & fcntl.LOCK_EX: + lock_path.rename(displaced_lock) + replacement_lock.rename(lock_path) + rebound = True + + monkeypatch.setattr("polylogue.storage.archive_identity.fcntl.flock", rebind_after_lock) + + with pytest.raises(ArchiveOwnershipError, match="pathname changed"): + OwnedArchiveLocation.acquire(ArchiveLocation.resolve(root)) + + assert rebound is True + assert lock_path.read_text(encoding="utf-8") == "foreign owner" + + def test_owned_location_reclaims_lock_left_by_dead_process(tmp_path: Path) -> None: root = tmp_path / "archive" root.mkdir() diff --git a/tests/unit/storage/test_archive_tiers_archive.py b/tests/unit/storage/test_archive_tiers_archive.py index 5b90ca39f8..41fb39da51 100644 --- a/tests/unit/storage/test_archive_tiers_archive.py +++ b/tests/unit/storage/test_archive_tiers_archive.py @@ -1,8 +1,11 @@ from __future__ import annotations +import os import sqlite3 +import stat from hashlib import sha256 from pathlib import Path +from types import TracebackType import pytest @@ -27,7 +30,13 @@ ParsedSession, ) from polylogue.storage.sqlite.action_relation import action_relation_select_sql -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveQueryUnitAggregateRow, ArchiveStore +from polylogue.storage.sqlite.archive_tiers.archive import ( + ArchiveQueryUnitAggregateRow, + ArchiveStore, + ReadOnlyArchiveError, +) +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root +from polylogue.storage.sqlite.archive_tiers.source_write import ArchiveHookEvent from polylogue.storage.sqlite.archive_tiers.user_write import ( assertion_id_for_session_metadata, assertion_id_for_session_tag, @@ -38,6 +47,64 @@ from tests.infra.workload_artifacts import build_seeded_archive +def test_active_archive_root_creation_is_private_under_permissive_umask(tmp_path: Path) -> None: + root = tmp_path / "private-archive" + previous_umask = os.umask(0) + try: + initialize_active_archive_root(root) + finally: + os.umask(previous_umask) + + assert stat.S_IMODE(root.stat().st_mode) == 0o700 + + +def test_active_archive_root_refuses_replacement_after_acquiring_ownership( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Bootstrap must not create tiers in a root its ownership token does not cover.""" + from polylogue.storage import archive_identity + from polylogue.storage.archive_identity import ArchiveOwnershipError + + root = tmp_path / "archive" + moved_root = tmp_path / "archive-owned" + root.mkdir() + real_acquire = archive_identity.OwnedArchiveLocation.acquire + + class ReplaceAfterAcquire: + def __init__(self, owned: archive_identity.OwnedArchiveLocation) -> None: + self._owned = owned + + def __enter__(self) -> archive_identity.OwnedArchiveLocation: + owned = self._owned.__enter__() + root.rename(moved_root) + root.mkdir() + return owned + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self._owned.__exit__(exc_type, exc, traceback) + + def acquire_then_replace( + location: archive_identity.ArchiveLocation, + *, + owner_id: str | None = None, + allow_reentrant: bool = False, + ) -> ReplaceAfterAcquire: + return ReplaceAfterAcquire(real_acquire(location, owner_id=owner_id, allow_reentrant=allow_reentrant)) + + monkeypatch.setattr(archive_identity.OwnedArchiveLocation, "acquire", acquire_then_replace) + + with pytest.raises(ArchiveOwnershipError, match="archive root changed during ownership validation"): + initialize_active_archive_root(root) + + assert not (root / "source.db").exists() + assert not (root / ".maintenance-state").exists() + + def test_active_archive_root_facade_writes_reads_and_searches_archive_db(tmp_path: Path) -> None: session = ParsedSession( source_name=Provider.CODEX, @@ -74,6 +141,83 @@ def test_open_existing_read_timeout_updates_busy_timeout(tmp_path: Path) -> None assert busy_timeout_ms == 250 +def test_pinned_read_only_store_blocks_all_archive_tier_mutations(tmp_path: Path) -> None: + """Pinned evidence reads never open writable source, index, or user tiers.""" + root = tmp_path / "archive" + initialize_active_archive_root(root) + session = ParsedSession( + source_name=Provider.CODEX, + provider_session_id="codex-pinned-read-only", + messages=[ + ParsedMessage( + provider_message_id="m1", + role=Role.USER, + blocks=[ParsedContentBlock(type=BlockType.TEXT, text="pinned evidence")], + ) + ], + ) + hook_event_id = "pinned-hook-event" + with ArchiveStore.open_existing(root, read_only=False) as archive: + session_id = archive.write_parsed(session) + archive.add_user_tags((session_id,), ("pinned",)) + archive.write_hook_event( + provider=Provider.CODEX, + payload=b'{"event":"PostToolUse"}', + source_path="hooks/pinned.jsonl", + acquired_at_ms=1_700_000_000_000, + hook_event=ArchiveHookEvent( + hook_event_id=hook_event_id, + origin=Origin.CODEX_SESSION, + source_path="hooks/pinned.jsonl", + event_type="PostToolUse", + payload={"event": "PostToolUse"}, + observed_at_ms=1_700_000_000_000, + native_id="pinned-hook-native", + session_native_id="codex-pinned-read-only", + ), + ) + + def durable_counts() -> tuple[int, int, int, int, int]: + with sqlite3.connect(root / "source.db") as source: + source_counts = ( + int(source.execute("SELECT COUNT(*) FROM raw_hook_events").fetchone()[0]), + int(source.execute("SELECT COUNT(*) FROM blob_refs").fetchone()[0]), + ) + with sqlite3.connect(root / "user.db") as user: + user_count = int(user.execute("SELECT COUNT(*) FROM assertions").fetchone()[0]) + with sqlite3.connect(root / "index.db") as index: + index_counts = ( + int(index.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]), + int(index.execute("SELECT COUNT(*) FROM blocks").fetchone()[0]), + ) + return (*source_counts, user_count, *index_counts) + + before = durable_counts() + index_path = (root / "index.db").resolve() + with ArchiveStore.open_existing(root, read_only=True, index_path=index_path) as archive: + assert archive.read_session(session_id).session_id == session_id + assert archive.list_user_tags() == {"pinned": 1} + hook_summary = archive.hook_event_summary_for_session(session_id) + assert hook_summary is not None + assert hook_summary["total"] == 1 + with pytest.raises(ReadOnlyArchiveError, match="read-only archive evidence"): + archive.delete_hook_event(hook_event_id) + with pytest.raises(ReadOnlyArchiveError, match="read-only archive evidence"): + archive.add_user_tags((session_id,), ("blocked",)) + with pytest.raises(ReadOnlyArchiveError, match="read-only archive evidence"): + archive.delete_sessions((session_id,)) + with pytest.raises(ReadOnlyArchiveError, match="read-only archive evidence"): + archive.rebuild_index() + with pytest.raises(ReadOnlyArchiveError, match="read-only archive evidence"): + archive.commit() + with pytest.raises(ReadOnlyArchiveError, match="read-only archive evidence"): + archive.classify_raw_revision_cohort_for_rebuild_repair("codex-session:codex-pinned-read-only") + with pytest.raises(ReadOnlyArchiveError, match="read-only archive evidence"): + archive.classify_raw_revision_cohort_for_live_watch("codex-session:codex-pinned-read-only") + + assert durable_counts() == before + + def test_archive_tiers_archive_facade_sorts_search_matches(tmp_path: Path) -> None: short = ParsedSession( source_name=Provider.CODEX, diff --git a/tests/unit/storage/test_connection_profile.py b/tests/unit/storage/test_connection_profile.py new file mode 100644 index 0000000000..a99f8383c5 --- /dev/null +++ b/tests/unit/storage/test_connection_profile.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +from polylogue.storage.sqlite import connection_profile + + +def test_open_readonly_connection_uses_descriptor_bound_database(tmp_path: Path) -> None: + db_path = tmp_path / "index.db" + with sqlite3.connect(db_path) as connection: + connection.execute("CREATE TABLE evidence (value TEXT)") + connection.execute("INSERT INTO evidence VALUES ('selected')") + + descriptor_handle = db_path.open("rb") + try: + reader = connection_profile.open_readonly_connection(db_path, opened_main_fd=descriptor_handle.fileno()) + try: + assert reader.execute("SELECT value FROM evidence").fetchone() == ("selected",) + finally: + reader.close() + finally: + descriptor_handle.close() + + +def test_open_readonly_connection_refuses_without_descriptor_bound_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db_path = tmp_path / "index.db" + with sqlite3.connect(db_path) as connection: + connection.execute("CREATE TABLE evidence (value TEXT)") + + descriptor_handle = db_path.open("rb") + try: + monkeypatch.setattr(connection_profile, "_descriptor_database_uri", lambda _fd, _suffix: None) + with pytest.raises(RuntimeError, match="descriptor-bound path"): + connection_profile.open_readonly_connection(db_path, opened_main_fd=descriptor_handle.fileno()) + finally: + descriptor_handle.close() + + +def test_open_readonly_connection_rejects_immutable_with_descriptor(tmp_path: Path) -> None: + db_path = tmp_path / "index.db" + with sqlite3.connect(db_path) as connection: + connection.execute("CREATE TABLE evidence (value TEXT)") + + descriptor_handle = db_path.open("rb") + try: + with pytest.raises(ValueError, match="immutable mode"): + connection_profile.open_readonly_connection( + db_path, + immutable=True, + opened_main_fd=descriptor_handle.fileno(), + ) + finally: + descriptor_handle.close() diff --git a/tests/unit/storage/test_prefix_dependent_delete_indexes.py b/tests/unit/storage/test_prefix_dependent_delete_indexes.py index e0e8748dea..ec4b5ce93b 100644 --- a/tests/unit/storage/test_prefix_dependent_delete_indexes.py +++ b/tests/unit/storage/test_prefix_dependent_delete_indexes.py @@ -12,13 +12,11 @@ reached the current version *before* that DDL addition landed never replays the DDL again (``initialize_archive_database`` only re-applies DDL for OPS/USER tiers on a same-version reopen) and only reads the index in via one -of the "ensure" call sites. The read-only path -(``ArchiveStore._ensure_read_runtime_indexes``) already had that ensure call; -the write-mode path did not -- meaning every ``ArchiveStore`` write open, -including ``open_owned_inactive_generation`` (used by bulk rebuilds/revision -backfill), could run its entire lifetime against an index.db missing these -indexes. This module asserts both the DDL-level index shape and the -write-open retrofit fix. +of the "ensure" call sites. Read-only opens deliberately do not repair the +selected file, while every write-mode open, including +``open_owned_inactive_generation`` (used by bulk rebuilds/revision backfill), +retrofits the runtime indexes. This module asserts both the DDL-level index +shape and the write-open retrofit fix. """ from __future__ import annotations diff --git a/tests/unit/storage/test_schema_policy_contracts.py b/tests/unit/storage/test_schema_policy_contracts.py index 6d24f6fe0e..0f24994015 100644 --- a/tests/unit/storage/test_schema_policy_contracts.py +++ b/tests/unit/storage/test_schema_policy_contracts.py @@ -180,8 +180,8 @@ def test_matching_version_database_ensures_runtime_indexes(tmp_path: Path) -> No conn.close() -def test_read_only_archive_open_ensures_runtime_indexes(tmp_path: Path) -> None: - """Read surfaces should not wait for a later write to gain runtime indexes.""" +def test_read_only_archive_open_does_not_ensure_runtime_indexes(tmp_path: Path) -> None: + """Read surfaces must not mutate an existing index to gain runtime indexes.""" initialize_active_archive_root(tmp_path) index_db = tmp_path / "index.db" conn = sqlite3.connect(index_db) @@ -201,11 +201,57 @@ def test_read_only_archive_open_ensures_runtime_indexes(tmp_path: Path) -> None: ("messages", "idx_messages_message_type"), ("messages", "idx_messages_material_origin"), ): - assert any(row[1] == index_name for row in conn.execute(f"PRAGMA index_list({table})")) + assert not any(row[1] == index_name for row in conn.execute(f"PRAGMA index_list({table})")) finally: conn.close() +def test_pinned_read_only_archive_open_does_not_mutate_physical_index_after_promotion( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A promoted active pointer cannot make a pinned read repair old evidence. + + The caller resolves the old physical index, then an ownership transition + promotes a different generation before the production ``open_existing`` + initialization path runs. The path must not reach the write-time runtime + index helper or mutate the now-inactive physical generation. + """ + old_root = tmp_path / "old-generation" + new_root = tmp_path / "new-generation" + archive_root = tmp_path / "archive" + initialize_active_archive_root(old_root) + initialize_active_archive_root(new_root) + archive_root.mkdir() + old_index = (old_root / "index.db").resolve() + new_index = (new_root / "index.db").resolve() + with sqlite3.connect(old_index) as conn: + conn.execute("CREATE TABLE pinned_evidence (value TEXT NOT NULL)") + conn.execute("INSERT INTO pinned_evidence VALUES ('old physical index')") + conn.execute("DROP INDEX idx_messages_message_type") + conn.commit() + assert not any(row[1] == "idx_messages_message_type" for row in conn.execute("PRAGMA index_list(messages)")) + + active_index = archive_root / "index.db" + active_index.symlink_to(old_index) + pinned_index = active_index.resolve(strict=True) + active_index.unlink() + active_index.symlink_to(new_index) + + def fail_if_write_time_indexes_run(_conn: sqlite3.Connection) -> None: + pytest.fail("pinned read entered the write-time runtime-index DDL path") + + monkeypatch.setattr( + "polylogue.storage.sqlite.archive_tiers.archive.ensure_runtime_indexes_sync", + fail_if_write_time_indexes_run, + ) + with ArchiveStore.open_existing(archive_root, index_path=pinned_index) as archive: + assert archive._read_only is True + assert tuple(archive._conn.execute("SELECT value FROM pinned_evidence").fetchone()) == ("old physical index",) + + with sqlite3.connect(old_index) as conn: + assert not any(row[1] == "idx_messages_message_type" for row in conn.execute("PRAGMA index_list(messages)")) + + def test_read_only_archive_open_does_not_bootstrap_missing_tiers(tmp_path: Path) -> None: """Read/status surfaces must not create an empty archive as a side effect.""" with pytest.raises(sqlite3.OperationalError):