Просмотр исходного кода

fix: make Polars artifact handoff durable

马小龙 3 месяцев назад
Родитель
Сommit
b1e1c8cf6a

+ 101 - 28
.superpowers/sdd/task-5-report.md

@@ -2,7 +2,9 @@
 
 
 Date: 2026-07-23
 Date: 2026-07-23
 Branch: `codex/data-rule-execution-m3a-m5`
 Branch: `codex/data-rule-execution-m3a-m5`
-Implementation commits: `69d83ed`, `f4d798b`, `8bffdb6`
+Implementation commits before the third remediation: `69d83ed`, `f4d798b`,
+`8bffdb6`, `c152e74`. The third-remediation commit is the commit containing
+this report revision.
 
 
 `8bffdb6` is the final hash of the second remediation commit. It replaces the
 `8bffdb6` is the final hash of the second remediation commit. It replaces the
 intermediate `a17bcd0` after restoring the historical M3A acceptance document
 intermediate `a17bcd0` after restoring the historical M3A acceptance document
@@ -33,9 +35,11 @@ The final implementation includes:
   group/aggregate, counts, and output write in an isolated spawn child;
   group/aggregate, counts, and output write in an isolated spawn child;
 - parent RSS monitoring, child OS `RLIMIT_AS`, bounded execution time, and
 - parent RSS monitoring, child OS `RLIMIT_AS`, bounded execution time, and
   fail-closed worker errors;
   fail-closed worker errors;
-- immutable catalog handoff keyed by
-  `(correlation_id, binding_id, artifact_kind)`;
-- production cleanup that removes the expired object and its catalog row;
+- durable `pending` → object upload → `ready` catalog handoff keyed by
+  `(correlation_id, binding_id, artifact_kind)`, with the exact attested
+  `binding_hash`;
+- bounded bidirectional reconciliation for pending, ready, missing, invalid,
+  and orphaned objects, plus the existing expiry cleanup;
 - minimal public artifact results without full schema fields;
 - minimal public artifact results without full schema fields;
 - standards-valid CycloneDX 1.5 runtime SBOM with deterministic offline schema
 - standards-valid CycloneDX 1.5 runtime SBOM with deterministic offline schema
   validation.
   validation.
@@ -93,39 +97,100 @@ Second-remediation GREEN:
 - `git diff --check`: passed.
 - `git diff --check`: passed.
 - `docker compose -f deploy/docker/docker-compose.yml config -q`: passed.
 - `docker compose -f deploy/docker/docker-compose.yml config -q`: passed.
 
 
-## Immutable artifact handoff, TTL, and cleanup
-
-Migration `20260723_140` now enforces:
+The third remediation supersedes the second-remediation migration and
+handoff implementation described below wherever they conflict.
+
+## Third-remediation RED/GREEN evidence
+
+The third review was also implemented in fail-first slices:
+
+- schema source tests failed until committed migration 140 was restored
+  byte-for-byte from `f4d798b` and the state change moved to new migration
+  `20260723_150`;
+- reservation-order tests failed until the resolver inserted a durable
+  `pending` row before the first MinIO PUT;
+- crash-injection tests failed until reserve, upload, and finalize had distinct
+  failure semantics and commit-acknowledgement loss was rechecked;
+- stale-binding tests failed until reservation and finalization locked and
+  attested the canonical binding in the same database transaction;
+- reconciliation tests failed until missing pending rows, valid pending
+  objects, missing ready objects, invalid objects, old orphans, fresh objects,
+  and unsafe keys had separate bounded policies;
+- worker-start injection initially leaked raw startup errors and pipe/process
+  handles; it now returns a safe error and closes every created endpoint in a
+  unified `finally`.
+
+Third-remediation GREEN:
+
+- focused schema/artifact/handoff/bootstrap/worker/adapter plus both real
+  integrations: `49 passed in 5.62s`;
+- isolated temporary PostgreSQL migration test: real old 140 schema, old-format
+  row insertion, upgrade to head, migrated `ready` row and `binding_hash`,
+  same-digest retry, different-digest CAS conflict: passed;
+- real source PostgreSQL + source MySQL + platform PostgreSQL + MinIO +
+  isolated Polars + Flask HTTP + durable ledger: passed;
+- final full suite: `564 passed, 26 skipped, 59 subtests passed in 9.01s`;
+- Ruff across every changed Python file: passed;
+- `git diff --check`: passed;
+- `docker compose -f deploy/docker/docker-compose.yml config -q`: passed;
+- `git diff f4d798b -- migrations/versions/20260723_140_rule_run_artifacts.py`:
+  empty.
+
+## Durable artifact handoff, attestation, and reconciliation
+
+Migration `20260723_140` is historical and unchanged. It retains its original
+unique constraint:
 
 
 ```text
 ```text
-UNIQUE (correlation_id, binding_id, artifact_kind)
+UNIQUE (correlation_id, binding_id, artifact_digest)
 ```
 ```
 
 
-Its downgrade raises `RuntimeError` because removing the catalog would violate
-immutable runtime handoff evidence.
-
-`PostgresArtifactResolver.register` implements the retry invariant:
-
-- first writer inserts and returns the new catalog artifact;
-- same triple + same digest reuses the existing catalog ref and deletes the
-  newly generated duplicate object;
-- same triple + different digest fails closed and deletes the new object;
-- existing catalog metadata is rechecked against the retained store object.
-
-Resolution requires the exact artifact kind, current binding hash, matching
-correlation prefix, valid TTL, digest, row count, and schema hash. The
-integration asserts one input, one lookup, and one output row/object after
-repeated deterministic execution.
+New forward-only migration `20260723_150`:
+
+- adds `binding_hash`, `handoff_status`, `ready_at`, `failed_at`,
+  `failure_code`, and `updated_at`;
+- migrates old rows to `ready` with the current canonical binding hash;
+- refuses unattested or conflicting old rows;
+- discovers and drops only the exact old-140 digest uniqueness constraint;
+- adds named uniqueness on
+  `(correlation_id, binding_id, artifact_kind)` and a reconciliation index.
+
+`PostgresArtifactResolver.publish_path` implements the durable protocol:
+
+1. validate the worker path and reserve a server-generated exact object key;
+2. in one transaction, lock the canonical binding with `FOR SHARE`, attest its
+   hash/object/access contract, and insert the exact `pending` catalog row;
+3. upload only to that reserved key and revalidate the stored object;
+4. in a new transaction, re-lock and re-attest the binding, then atomically
+   finalize only the matching pending row to `ready`.
+
+Resolution sees only `ready` rows whose persisted binding hash still equals the
+canonical binding hash. Same-triple/same-digest retries reuse the ready object
+without uploading; different digests fail before upload. Upload failure removes
+the exact object and pending row. A lost finalize acknowledgement is rechecked:
+confirmed ready is success; unconfirmed state returns
+`commit_outcome="unknown"` and deliberately does not delete the possibly
+cataloged object.
+
+`reconcile-rule-artifacts --limit --grace-seconds` processes only aged,
+bounded candidates. It deletes missing pending rows, finalizes valid pending
+objects only while the binding still matches, marks ready rows failed when
+their object is missing or invalid, deletes invalid pending objects safely, and
+deletes only old safe-key orphans after the grace period. Fresh objects,
+referenced objects, and malformed/unowned keys are retained.
 
 
 `cleanup-rule-artifacts --limit` uses a bounded
 `cleanup-rule-artifacts --limit` uses a bounded
 `FOR UPDATE SKIP LOCKED` selection, removes each expired store object, and
 `FOR UPDATE SKIP LOCKED` selection, removes each expired store object, and
 deletes its exact catalog row in the same database transaction scope. Unit
 deletes its exact catalog row in the same database transaction scope. Unit
 tests cover the CLI and object+row cleanup behavior.
 tests cover the CLI and object+row cleanup behavior.
 
 
-The local database had an earlier applied copy of migration 140. Before real
-integration, its artifact table was verified empty and its old unique
-constraint was aligned to the final triple invariant. Migration source tests
-verify the clean-install definition.
+The local database had an earlier applied empty copy of migration 140 with a
+manually altered constraint. It was explicitly not accepted as migration
+evidence. After verifying zero rows, only that empty table was rebuilt by
+rewinding its Alembic marker to 130 and running the formal
+130 → original-140 → 150 chain. Independently, the automated acceptance test
+creates and drops a fresh temporary database and proves old140 → head without
+manual ALTER.
 
 
 ## Resource enforcement and worker isolation
 ## Resource enforcement and worker isolation
 
 
@@ -163,6 +228,11 @@ The parent independently watches incremental RSS with psutil, kills the child
 on limit breach, enforces a hard timeout, and returns only bounded metrics.
 on limit breach, enforces a hard timeout, and returns only bounded metrics.
 Worker PID is deliberately not exposed in the public result.
 Worker PID is deliberately not exposed in the public result.
 
 
+Pipe creation, process construction, `process.start()`, message handling, and
+resource enforcement now share one guarded lifecycle. Startup errors are
+sanitized, both pipe endpoints are closed when created, and a started process
+is killed/joined/closed from the unified `finally`.
+
 Tests prove:
 Tests prove:
 
 
 - worker PID differs from the Runner PID;
 - worker PID differs from the Runner PID;
@@ -215,7 +285,7 @@ Evidence:
 - 1 deduplicated row;
 - 1 deduplicated row;
 - exact enriched values verified after reread;
 - exact enriched values verified after reread;
 - direct repeat reuses the stable output ref;
 - direct repeat reuses the stable output ref;
-- conflicting output digest fails and its new object is absent;
+- conflicting output digest fails before a new object is uploaded;
 - HTTP result returns the same stable output ref;
 - HTTP result returns the same stable output ref;
 - replaying the same token returns HTTP 409;
 - replaying the same token returns HTTP 409;
 - durable ledger records `success` / `committed`;
 - durable ledger records `success` / `committed`;
@@ -258,6 +328,7 @@ Production:
 - `app/runner/rule_polars.py`
 - `app/runner/rule_polars.py`
 - `app/runner/bootstrap.py`
 - `app/runner/bootstrap.py`
 - `migrations/versions/20260723_140_rule_run_artifacts.py`
 - `migrations/versions/20260723_140_rule_run_artifacts.py`
+- `migrations/versions/20260723_150_rule_artifact_handoff_state.py`
 - `requirements.txt`
 - `requirements.txt`
 - `docs/security/data-rule-runtime-sbom.json`
 - `docs/security/data-rule-runtime-sbom.json`
 - `docs/security/cyclonedx-1.5-schema/`
 - `docs/security/cyclonedx-1.5-schema/`
@@ -266,10 +337,12 @@ Tests:
 
 
 - `tests/core/data_rules/test_polars_compiler.py`
 - `tests/core/data_rules/test_polars_compiler.py`
 - `tests/runner/test_artifacts.py`
 - `tests/runner/test_artifacts.py`
+- `tests/runner/test_artifact_handoff.py`
 - `tests/runner/test_polars_worker.py`
 - `tests/runner/test_polars_worker.py`
 - `tests/runner/test_rule_polars.py`
 - `tests/runner/test_rule_polars.py`
 - `tests/runner/test_bootstrap.py`
 - `tests/runner/test_bootstrap.py`
 - `tests/integration/test_data_rule_polars_execution.py`
 - `tests/integration/test_data_rule_polars_execution.py`
+- `tests/integration/test_rule_artifact_migration_upgrade.py`
 - `tests/test_data_rule_runtime_sbom.py`
 - `tests/test_data_rule_runtime_sbom.py`
 - `tests/test_data_rule_schema.py`
 - `tests/test_data_rule_schema.py`
 
 

+ 714 - 115
app/runner/artifacts.py

@@ -14,6 +14,7 @@ from typing import Any
 
 
 import polars as pl
 import polars as pl
 import pyarrow.parquet as pq
 import pyarrow.parquet as pq
+from minio.error import S3Error
 from sqlalchemy import text
 from sqlalchemy import text
 
 
 from app.core.common.identifiers import (
 from app.core.common.identifiers import (
@@ -26,6 +27,14 @@ PARQUET_CONTENT_TYPE = "application/x-parquet"
 _DIGEST = re.compile(r"^[0-9a-f]{64}$")
 _DIGEST = re.compile(r"^[0-9a-f]{64}$")
 
 
 
 
+class ArtifactCommitUnknown(RuntimeError):
+    """A catalog transaction may have committed but cannot be confirmed."""
+
+
+class ArtifactHandoffPending(RuntimeError):
+    """Another publisher owns the durable pending handoff."""
+
+
 def _parquet_footer_bounds(path: str) -> tuple[int, int]:
 def _parquet_footer_bounds(path: str) -> tuple[int, int]:
     try:
     try:
         metadata = pq.ParquetFile(path).metadata
         metadata = pq.ParquetFile(path).metadata
@@ -436,7 +445,7 @@ class ArtifactStore:
             "expires_at": expires_at,
             "expires_at": expires_at,
         }
         }
 
 
-    def write_path(
+    def prepare_path(
         self,
         self,
         path: str,
         path: str,
         correlation_id: str,
         correlation_id: str,
@@ -445,7 +454,7 @@ class ArtifactStore:
         schema_fields: list[dict[str, Any]],
         schema_fields: list[dict[str, Any]],
         limits: dict[str, int] | None = None,
         limits: dict[str, int] | None = None,
     ) -> dict[str, Any]:
     ) -> dict[str, Any]:
-        """Upload a worker-produced Parquet file without collecting it."""
+        """Validate a local Parquet file and reserve its server-owned key."""
 
 
         effective = self._limits(limits)
         effective = self._limits(limits)
         correlation = _uid(correlation_id, "correlation_id")
         correlation = _uid(correlation_id, "correlation_id")
@@ -480,6 +489,63 @@ class ArtifactStore:
         )
         )
         artifact_id = new_governance_uid()
         artifact_id = new_governance_uid()
         key = f"rules/{correlation}/{artifact_id}.parquet"
         key = f"rules/{correlation}/{artifact_id}.parquet"
+        return {
+            "artifact_ref": f"minio://{self.bucket}/{key}",
+            "digest": digest_hex,
+            "row_count": rows,
+            "schema_hash": schema_digest,
+            "schema_fields": fields,
+            "expires_at": expires_at,
+        }
+
+    def upload_path(
+        self,
+        path: str,
+        artifact: dict[str, Any],
+        *,
+        limits: dict[str, int] | None = None,
+    ) -> dict[str, Any]:
+        """Upload to an already reserved exact key and verify the object."""
+
+        if not isinstance(artifact, dict):
+            raise ValueError("prepared artifact metadata is invalid")
+        required = {
+            "artifact_ref",
+            "digest",
+            "row_count",
+            "schema_hash",
+            "schema_fields",
+            "expires_at",
+        }
+        if set(artifact) != required:
+            raise ValueError("prepared artifact metadata has a closed shape")
+        key = self._parse_ref(artifact["artifact_ref"])
+        correlation = key.split("/", 2)[1]
+        ttl_seconds = int(
+            (
+                _parse_timestamp(artifact["expires_at"])
+                - _now_utc(self.clock)
+            ).total_seconds()
+        )
+        if ttl_seconds < 1 or ttl_seconds > self.max_ttl_seconds:
+            raise ValueError("prepared artifact TTL is invalid")
+        expected = self.prepare_path(
+            path,
+            correlation,
+            ttl_seconds,
+            schema_fields=artifact["schema_fields"],
+            limits=limits,
+        )
+        for name in (
+            "digest",
+            "row_count",
+            "schema_hash",
+            "schema_fields",
+        ):
+            if expected[name] != artifact[name]:
+                raise ValueError("prepared artifact no longer matches its path")
+        effective = self._limits(limits)
+        size = os.path.getsize(path)
         uploaded = False
         uploaded = False
         try:
         try:
             with open(path, "rb") as handle:
             with open(path, "rb") as handle:
@@ -490,19 +556,18 @@ class ArtifactStore:
                     size,
                     size,
                     content_type=PARQUET_CONTENT_TYPE,
                     content_type=PARQUET_CONTENT_TYPE,
                     metadata={
                     metadata={
-                        "sha256": digest_hex,
-                        "row-count": str(rows),
-                        "schema-sha256": schema_digest,
-                        "expires-at": expires_at,
+                        "sha256": artifact["digest"],
+                        "row-count": str(artifact["row_count"]),
+                        "schema-sha256": artifact["schema_hash"],
+                        "expires-at": artifact["expires_at"],
                         "artifact-bytes": str(size),
                         "artifact-bytes": str(size),
                     },
                     },
                 )
                 )
             uploaded = True
             uploaded = True
-            artifact_ref = f"minio://{self.bucket}/{key}"
             with self.stage(
             with self.stage(
-                artifact_ref,
-                digest_hex,
-                expected_schema_fields=fields,
+                artifact["artifact_ref"],
+                artifact["digest"],
+                expected_schema_fields=artifact["schema_fields"],
                 limits=effective,
                 limits=effective,
             ):
             ):
                 pass
                 pass
@@ -511,14 +576,27 @@ class ArtifactStore:
                 with suppress(Exception):
                 with suppress(Exception):
                     self.client.remove_object(self.bucket, key)
                     self.client.remove_object(self.bucket, key)
             raise
             raise
-        return {
-            "artifact_ref": artifact_ref,
-            "digest": digest_hex,
-            "row_count": rows,
-            "schema_hash": schema_digest,
-            "schema_fields": fields,
-            "expires_at": expires_at,
-        }
+        return dict(artifact)
+
+    def write_path(
+        self,
+        path: str,
+        correlation_id: str,
+        ttl_seconds: int,
+        *,
+        schema_fields: list[dict[str, Any]],
+        limits: dict[str, int] | None = None,
+    ) -> dict[str, Any]:
+        """Prepare and upload a non-cataloged compatibility artifact."""
+
+        artifact = self.prepare_path(
+            path,
+            correlation_id,
+            ttl_seconds,
+            schema_fields=schema_fields,
+            limits=limits,
+        )
+        return self.upload_path(path, artifact, limits=limits)
 
 
     def describe(self, ref: str) -> dict[str, Any]:
     def describe(self, ref: str) -> dict[str, Any]:
         """Return validated object metadata without exposing MinIO credentials."""
         """Return validated object metadata without exposing MinIO credentials."""
@@ -533,6 +611,18 @@ class ArtifactStore:
             "expires_at": metadata["expires-at"],
             "expires_at": metadata["expires-at"],
         }
         }
 
 
+    def describe_optional(self, ref: str) -> dict[str, Any] | None:
+        """Return None only for a confirmed missing object."""
+
+        try:
+            return self.describe(ref)
+        except KeyError:
+            return None
+        except S3Error as exc:
+            if exc.code in {"NoSuchKey", "NoSuchObject", "NotFound"}:
+                return None
+            raise
+
     def read(
     def read(
         self,
         self,
         ref: str,
         ref: str,
@@ -692,14 +782,17 @@ class PostgresArtifactResolver:
                 a.schema_hash,
                 a.schema_hash,
                 a.schema_fields,
                 a.schema_fields,
                 a.expires_at,
                 a.expires_at,
-                b.binding_hash
+                a.binding_hash AS catalog_binding_hash,
+                b.binding_hash AS current_binding_hash
             FROM public.rule_run_artifacts a
             FROM public.rule_run_artifacts a
             JOIN public.dataflow_dataset_bindings b
             JOIN public.dataflow_dataset_bindings b
               ON b.id = a.binding_id
               ON b.id = a.binding_id
             WHERE a.binding_id = CAST(:binding_id AS uuid)
             WHERE a.binding_id = CAST(:binding_id AS uuid)
               AND a.correlation_id = CAST(:correlation_id AS uuid)
               AND a.correlation_id = CAST(:correlation_id AS uuid)
               AND a.artifact_kind = :artifact_kind
               AND a.artifact_kind = :artifact_kind
+              AND a.handoff_status = 'ready'
               AND a.expires_at > CURRENT_TIMESTAMP
               AND a.expires_at > CURRENT_TIMESTAMP
+              AND a.binding_hash = b.binding_hash
               AND b.object_kind = 'parquet_artifact'
               AND b.object_kind = 'parquet_artifact'
               AND b.access_mode IN ('read', 'read_write')
               AND b.access_mode IN ('read', 'read_write')
             ORDER BY a.created_at DESC
             ORDER BY a.created_at DESC
@@ -736,7 +829,7 @@ class PostgresArtifactResolver:
         return {
         return {
             **described,
             **described,
             "schema_fields": _normalized_schema_fields(row_fields),
             "schema_fields": _normalized_schema_fields(row_fields),
-            "binding_hash": str(row["binding_hash"]),
+            "binding_hash": str(row["catalog_binding_hash"]),
         }
         }
 
 
     def attest_binding(
     def attest_binding(
@@ -775,7 +868,88 @@ class PostgresArtifactResolver:
             raise ValueError("canonical artifact binding no longer matches")
             raise ValueError("canonical artifact binding no longer matches")
         return {"binding_hash": str(row["binding_hash"])}
         return {"binding_hash": str(row["binding_hash"])}
 
 
-    def register(
+    @staticmethod
+    def _catalog_artifact(row: Mapping[str, Any]) -> dict[str, Any]:
+        fields = row["schema_fields"]
+        if isinstance(fields, str):
+            fields = json.loads(fields)
+        expires_at = row["expires_at"]
+        return {
+            "artifact_ref": str(row["artifact_ref"]),
+            "digest": str(row["artifact_digest"]),
+            "row_count": int(row["row_count"]),
+            "schema_hash": str(row["schema_hash"]),
+            "schema_fields": _normalized_schema_fields(fields),
+            "expires_at": (
+                _timestamp(expires_at)
+                if isinstance(expires_at, datetime)
+                else str(expires_at)
+            ),
+        }
+
+    @staticmethod
+    def _attest_binding_locked(
+        connection,
+        *,
+        binding_id: str,
+        binding_hash: str,
+        kind: str,
+    ) -> None:
+        row = connection.execute(
+            text(
+                """
+                SELECT binding_hash, access_mode, object_kind
+                FROM public.dataflow_dataset_bindings
+                WHERE id = CAST(:binding_id AS uuid)
+                FOR SHARE
+                """
+            ),
+            {"binding_id": binding_id},
+        ).mappings().one_or_none()
+        allowed = (
+            {"write", "read_write"}
+            if kind == "output"
+            else {"read", "read_write"}
+        )
+        if (
+            row is None
+            or row["object_kind"] != "parquet_artifact"
+            or row["access_mode"] not in allowed
+            or str(row["binding_hash"]) != binding_hash
+        ):
+            raise ValueError("canonical artifact binding no longer matches")
+
+    def _lookup_handoff(
+        self,
+        *,
+        correlation_id: str,
+        binding_id: str,
+        kind: str,
+    ) -> dict[str, Any] | None:
+        with self.engine.connect() as connection:
+            row = connection.execute(
+                text(
+                    """
+                    SELECT id::text AS id, correlation_id::text,
+                           binding_id::text, artifact_ref, artifact_digest,
+                           row_count, schema_hash, schema_fields,
+                           artifact_kind, binding_hash, expires_at,
+                           handoff_status
+                    FROM public.rule_run_artifacts
+                    WHERE correlation_id = CAST(:correlation_id AS uuid)
+                      AND binding_id = CAST(:binding_id AS uuid)
+                      AND artifact_kind = :artifact_kind
+                    """
+                ),
+                {
+                    "correlation_id": correlation_id,
+                    "binding_id": binding_id,
+                    "artifact_kind": kind,
+                },
+            ).mappings().one_or_none()
+        return dict(row) if row is not None else None
+
+    def reserve(
         self,
         self,
         *,
         *,
         binding_id: str,
         binding_id: str,
@@ -784,15 +958,14 @@ class PostgresArtifactResolver:
         kind: str,
         kind: str,
         binding_hash: str,
         binding_hash: str,
     ) -> dict[str, Any]:
     ) -> dict[str, Any]:
+        """Atomically attest the binding and reserve one pending handoff."""
+
         binding = _uid(binding_id, "artifact binding id")
         binding = _uid(binding_id, "artifact binding id")
         correlation = _uid(correlation_id, "artifact correlation id")
         correlation = _uid(correlation_id, "artifact correlation id")
         if kind not in {"input", "lookup", "output"}:
         if kind not in {"input", "lookup", "output"}:
             raise ValueError("artifact kind is invalid")
             raise ValueError("artifact kind is invalid")
-        self.attest_binding(
-            binding_id=binding,
-            binding_hash=binding_hash,
-            access_mode="write" if kind == "output" else "read",
-        )
+        if _DIGEST.fullmatch(str(binding_hash or "")) is None:
+            raise ValueError("artifact binding hash is invalid")
         if not isinstance(artifact, dict):
         if not isinstance(artifact, dict):
             raise ValueError("artifact metadata is invalid")
             raise ValueError("artifact metadata is invalid")
         artifact_ref = artifact.get("artifact_ref")
         artifact_ref = artifact.get("artifact_ref")
@@ -801,118 +974,544 @@ class PostgresArtifactResolver:
             raise ValueError(
             raise ValueError(
                 "artifact does not match the execution correlation"
                 "artifact does not match the execution correlation"
             )
             )
-        described = self.artifact_store.describe(artifact_ref)
-        for key in (
-            "artifact_ref",
-            "digest",
-            "row_count",
-            "schema_hash",
-            "expires_at",
-        ):
-            if described[key] != artifact.get(key):
-                raise ValueError("artifact metadata does not match storage")
         fields = _normalized_schema_fields(artifact.get("schema_fields"))
         fields = _normalized_schema_fields(artifact.get("schema_fields"))
-        if canonical_schema_hash(fields) != described["schema_hash"]:
-            raise ValueError("artifact schema contract does not match storage")
-        inserted = None
+        if canonical_schema_hash(fields) != artifact.get("schema_hash"):
+            raise ValueError("artifact schema contract does not match")
+        if _DIGEST.fullmatch(str(artifact.get("digest") or "")) is None:
+            raise ValueError("artifact digest is invalid")
+        reservation_id = new_governance_uid()
+        parameters = {
+            "id": reservation_id,
+            "correlation_id": correlation,
+            "binding_id": binding,
+            "artifact_ref": artifact_ref,
+            "artifact_digest": artifact["digest"],
+            "row_count": int(artifact["row_count"]),
+            "schema_hash": artifact["schema_hash"],
+            "schema_fields": json.dumps(
+                fields,
+                sort_keys=True,
+                separators=(",", ":"),
+            ),
+            "artifact_kind": kind,
+            "binding_hash": binding_hash,
+            "expires_at": artifact["expires_at"],
+        }
+        selected = None
+        inserted = False
+        try:
+            with self.engine.begin() as connection:
+                self._attest_binding_locked(
+                    connection,
+                    binding_id=binding,
+                    binding_hash=binding_hash,
+                    kind=kind,
+                )
+                selected = connection.execute(
+                    text(
+                        """
+                        INSERT INTO public.rule_run_artifacts (
+                            id, correlation_id, binding_id, artifact_ref,
+                            artifact_digest, row_count, schema_hash,
+                            schema_fields, artifact_kind, binding_hash,
+                            handoff_status, expires_at
+                        ) VALUES (
+                            CAST(:id AS uuid),
+                            CAST(:correlation_id AS uuid),
+                            CAST(:binding_id AS uuid), :artifact_ref,
+                            :artifact_digest, :row_count, :schema_hash,
+                            CAST(:schema_fields AS jsonb), :artifact_kind,
+                            :binding_hash, 'pending',
+                            CAST(:expires_at AS timestamptz)
+                        )
+                        ON CONFLICT (
+                            correlation_id, binding_id, artifact_kind
+                        ) DO NOTHING
+                        RETURNING id::text AS id, correlation_id::text,
+                                  binding_id::text, artifact_ref,
+                                  artifact_digest, row_count, schema_hash,
+                                  schema_fields, artifact_kind, binding_hash,
+                                  expires_at, handoff_status
+                        """
+                    ),
+                    parameters,
+                ).mappings().one_or_none()
+                inserted = selected is not None
+                if selected is None:
+                    selected = connection.execute(
+                        text(
+                            """
+                            SELECT id::text AS id, correlation_id::text,
+                                   binding_id::text, artifact_ref,
+                                   artifact_digest, row_count, schema_hash,
+                                   schema_fields, artifact_kind, binding_hash,
+                                   expires_at, handoff_status
+                            FROM public.rule_run_artifacts
+                            WHERE correlation_id =
+                                      CAST(:correlation_id AS uuid)
+                              AND binding_id = CAST(:binding_id AS uuid)
+                              AND artifact_kind = :artifact_kind
+                            FOR UPDATE
+                            """
+                        ),
+                        parameters,
+                    ).mappings().one_or_none()
+        except ValueError:
+            raise
+        except Exception as exc:
+            try:
+                selected = self._lookup_handoff(
+                    correlation_id=correlation,
+                    binding_id=binding,
+                    kind=kind,
+                )
+            except Exception as recheck_exc:
+                raise ArtifactCommitUnknown(
+                    "artifact reservation commit outcome is unknown"
+                ) from recheck_exc
+            if (
+                selected is None
+                or str(selected["artifact_ref"]) != artifact_ref
+                or str(selected["artifact_digest"]) != artifact["digest"]
+                or str(selected["binding_hash"]) != binding_hash
+            ):
+                raise ArtifactCommitUnknown(
+                    "artifact reservation commit outcome is unknown"
+                ) from exc
+            inserted = True
+        if selected is None:
+            raise ArtifactCommitUnknown(
+                "artifact reservation commit outcome is unknown"
+            )
+        row = dict(selected)
+        if str(row["binding_hash"]) != binding_hash:
+            raise ValueError("artifact reservation binding hash conflicts")
+        if str(row["artifact_digest"]) != artifact["digest"]:
+            raise ValueError(
+                "immutable artifact catalog digest conflicts with retry"
+            )
+        status = str(row["handoff_status"])
+        if not inserted:
+            if status == "ready":
+                return {
+                    **self._catalog_artifact(row),
+                    "correlation_id": correlation,
+                    "reservation_id": str(row["id"]),
+                    "handoff_status": "ready",
+                    "upload_required": False,
+                }
+            if status == "pending":
+                raise ArtifactHandoffPending(
+                    "artifact handoff is already pending"
+                )
+            raise ValueError("artifact handoff has failed")
+        return {
+            **self._catalog_artifact(row),
+            "correlation_id": correlation,
+            "reservation_id": str(row["id"]),
+            "handoff_status": status,
+            "upload_required": status == "pending",
+        }
+
+    def _abort_pending(self, reservation_id: str) -> None:
         with self.engine.begin() as connection:
         with self.engine.begin() as connection:
-            inserted = connection.execute(
+            connection.execute(
                 text(
                 text(
                     """
                     """
-                    INSERT INTO public.rule_run_artifacts (
-                        id, correlation_id, binding_id, artifact_ref,
-                        artifact_digest, row_count, schema_hash, schema_fields,
-                        artifact_kind, expires_at
-                    ) VALUES (
-                        CAST(:id AS uuid), CAST(:correlation_id AS uuid),
-                        CAST(:binding_id AS uuid), :artifact_ref,
-                        :artifact_digest, :row_count, :schema_hash,
-                        CAST(:schema_fields AS jsonb), :artifact_kind,
-                        CAST(:expires_at AS timestamptz)
-                    )
-                    ON CONFLICT (
-                        correlation_id, binding_id, artifact_kind
-                    ) DO NOTHING
-                    RETURNING artifact_ref, artifact_digest, row_count,
-                              schema_hash, schema_fields, expires_at
+                    DELETE FROM public.rule_run_artifacts
+                    WHERE id = CAST(:id AS uuid)
+                      AND handoff_status = 'pending'
                     """
                     """
                 ),
                 ),
-                {
-                    "id": new_governance_uid(),
-                    "correlation_id": correlation,
-                    "binding_id": binding,
-                    "artifact_ref": described["artifact_ref"],
-                    "artifact_digest": described["digest"],
-                    "row_count": described["row_count"],
-                    "schema_hash": described["schema_hash"],
-                    "schema_fields": json.dumps(
-                        fields,
-                        sort_keys=True,
-                        separators=(",", ":"),
-                    ),
-                    "artifact_kind": kind,
-                    "expires_at": described["expires_at"],
-                },
-            ).mappings().one_or_none()
-            if inserted is None:
-                inserted = connection.execute(
+                {"id": reservation_id},
+            )
+
+    def finalize(
+        self,
+        *,
+        reservation: dict[str, Any],
+        binding_id: str,
+        binding_hash: str,
+        kind: str,
+    ) -> dict[str, Any]:
+        reservation_id = _uid(
+            reservation.get("reservation_id"), "artifact reservation id"
+        )
+        binding = _uid(binding_id, "artifact binding id")
+        selected = None
+        try:
+            with self.engine.begin() as connection:
+                self._attest_binding_locked(
+                    connection,
+                    binding_id=binding,
+                    binding_hash=binding_hash,
+                    kind=kind,
+                )
+                selected = connection.execute(
                     text(
                     text(
                         """
                         """
-                        SELECT artifact_ref, artifact_digest, row_count,
-                               schema_hash, schema_fields, expires_at
-                        FROM public.rule_run_artifacts
-                        WHERE correlation_id = CAST(:correlation_id AS uuid)
+                        UPDATE public.rule_run_artifacts
+                        SET handoff_status = 'ready',
+                            ready_at = CURRENT_TIMESTAMP,
+                            updated_at = CURRENT_TIMESTAMP,
+                            failure_code = NULL,
+                            failed_at = NULL
+                        WHERE id = CAST(:id AS uuid)
                           AND binding_id = CAST(:binding_id AS uuid)
                           AND binding_id = CAST(:binding_id AS uuid)
-                          AND artifact_kind = :artifact_kind
-                        FOR UPDATE
+                          AND binding_hash = :binding_hash
+                          AND artifact_digest = :artifact_digest
+                          AND handoff_status = 'pending'
+                        RETURNING id::text AS id, correlation_id::text,
+                                  binding_id::text, artifact_ref,
+                                  artifact_digest, row_count, schema_hash,
+                                  schema_fields, artifact_kind, binding_hash,
+                                  expires_at, handoff_status
                         """
                         """
                     ),
                     ),
                     {
                     {
-                        "correlation_id": correlation,
+                        "id": reservation_id,
                         "binding_id": binding,
                         "binding_id": binding,
-                        "artifact_kind": kind,
+                        "binding_hash": binding_hash,
+                        "artifact_digest": reservation["digest"],
                     },
                     },
                 ).mappings().one_or_none()
                 ).mappings().one_or_none()
-        if inserted is None:
-            self.artifact_store.delete(artifact_ref)
-            raise ValueError("immutable artifact catalog handoff was not found")
-        existing = dict(inserted)
-        existing_fields = existing["schema_fields"]
-        if isinstance(existing_fields, str):
-            existing_fields = json.loads(existing_fields)
-        registered = {
-            "artifact_ref": str(existing["artifact_ref"]),
-            "digest": str(existing["artifact_digest"]),
-            "row_count": int(existing["row_count"]),
-            "schema_hash": str(existing["schema_hash"]),
-            "schema_fields": _normalized_schema_fields(existing_fields),
-            "expires_at": (
-                _timestamp(existing["expires_at"])
-                if isinstance(existing["expires_at"], datetime)
-                else str(existing["expires_at"])
-            ),
-        }
-        if registered["digest"] != described["digest"]:
-            self.artifact_store.delete(artifact_ref)
-            raise ValueError(
-                "immutable artifact catalog digest conflicts with retry"
-            )
-        if registered["artifact_ref"] != artifact_ref:
-            self.artifact_store.delete(artifact_ref)
+                if selected is None:
+                    raise ValueError(
+                        "pending artifact handoff no longer matches"
+                    )
+        except ValueError:
+            raise
+        except Exception as exc:
+            try:
+                selected = self._lookup_handoff(
+                    correlation_id=_uid(
+                        reservation["correlation_id"],
+                        "artifact correlation id",
+                    ),
+                    binding_id=binding,
+                    kind=kind,
+                )
+            except Exception as recheck_exc:
+                raise ArtifactCommitUnknown(
+                    "artifact finalize commit outcome is unknown"
+                ) from recheck_exc
+            if (
+                selected is None
+                or str(selected["id"]) != reservation_id
+                or str(selected["artifact_digest"])
+                != reservation["digest"]
+                or str(selected["binding_hash"]) != binding_hash
+                or str(selected["handoff_status"]) != "ready"
+            ):
+                raise ArtifactCommitUnknown(
+                    "artifact finalize commit outcome is unknown"
+                ) from exc
+        return self._catalog_artifact(selected)
+
+    def publish_path(
+        self,
+        path: str,
+        *,
+        binding_id: str,
+        binding_hash: str,
+        correlation_id: str,
+        kind: str,
+        ttl_seconds: int,
+        schema_fields: list[dict[str, Any]],
+        limits: dict[str, int] | None = None,
+    ) -> dict[str, Any]:
+        """Reserve, upload, and finalize one durable artifact handoff."""
+
+        prepared = self.artifact_store.prepare_path(
+            path,
+            correlation_id,
+            ttl_seconds,
+            schema_fields=schema_fields,
+            limits=limits,
+        )
+        reservation = self.reserve(
+            binding_id=binding_id,
+            correlation_id=correlation_id,
+            artifact=prepared,
+            kind=kind,
+            binding_hash=binding_hash,
+        )
+        if not reservation["upload_required"]:
             stored = self.artifact_store.describe(
             stored = self.artifact_store.describe(
-                registered["artifact_ref"]
+                reservation["artifact_ref"]
             )
             )
+            if stored["digest"] != reservation["digest"]:
+                raise ValueError(
+                    "ready artifact catalog does not match storage"
+                )
+            return {
+                key: reservation[key]
+                for key in (
+                    "artifact_ref",
+                    "digest",
+                    "row_count",
+                    "schema_hash",
+                    "schema_fields",
+                    "expires_at",
+                )
+            }
+        reserved_artifact = {
+            key: reservation[key]
             for key in (
             for key in (
+                "artifact_ref",
                 "digest",
                 "digest",
                 "row_count",
                 "row_count",
                 "schema_hash",
                 "schema_hash",
+                "schema_fields",
                 "expires_at",
                 "expires_at",
-            ):
-                if stored[key] != registered[key]:
-                    raise ValueError(
-                        "immutable artifact catalog does not match storage"
+            )
+        }
+        try:
+            self.artifact_store.upload_path(
+                path,
+                reserved_artifact,
+                limits=limits,
+            )
+        except Exception:
+            with suppress(Exception):
+                self.artifact_store.delete(
+                    reserved_artifact["artifact_ref"]
+                )
+            with suppress(Exception):
+                self._abort_pending(reservation["reservation_id"])
+            raise
+        return self.finalize(
+            reservation=reservation,
+            binding_id=binding_id,
+            binding_hash=binding_hash,
+            kind=kind,
+        )
+
+    def _mark_failed(
+        self,
+        *,
+        row_id: str,
+        failure_code: str,
+    ) -> None:
+        with self.engine.begin() as connection:
+            connection.execute(
+                text(
+                    """
+                    UPDATE public.rule_run_artifacts
+                    SET handoff_status = 'failed',
+                        failed_at = CURRENT_TIMESTAMP,
+                        updated_at = CURRENT_TIMESTAMP,
+                        failure_code = :failure_code
+                    WHERE id = CAST(:id AS uuid)
+                      AND handoff_status IN ('pending','ready')
+                    """
+                ),
+                {"id": row_id, "failure_code": failure_code},
+            )
+
+    def reconcile(
+        self,
+        *,
+        limit: int = 100,
+        grace_seconds: int = 300,
+    ) -> dict[str, int]:
+        """Repair bounded catalog/store drift after the grace period."""
+
+        if (
+            isinstance(limit, bool)
+            or not isinstance(limit, int)
+            or limit < 1
+            or limit > 1_000
+        ):
+            raise ValueError("artifact reconciliation limit is invalid")
+        if (
+            isinstance(grace_seconds, bool)
+            or not isinstance(grace_seconds, int)
+            or grace_seconds < 30
+            or grace_seconds > 86_400
+        ):
+            raise ValueError("artifact reconciliation grace is invalid")
+        result = {
+            "pending_finalized": 0,
+            "pending_deleted": 0,
+            "ready_failed": 0,
+            "orphans_deleted": 0,
+        }
+        with self.engine.connect() as connection:
+            rows = connection.execute(
+                text(
+                    """
+                    SELECT id::text AS id, correlation_id::text,
+                           binding_id::text, artifact_ref, artifact_digest,
+                           row_count, schema_hash, schema_fields,
+                           artifact_kind, binding_hash, expires_at,
+                           handoff_status
+                    FROM public.rule_run_artifacts
+                    WHERE handoff_status IN ('pending','ready')
+                      AND updated_at <= CURRENT_TIMESTAMP
+                          - make_interval(secs => :grace_seconds)
+                    ORDER BY updated_at, id
+                    LIMIT :limit
+                    """
+                ),
+                {
+                    "grace_seconds": grace_seconds,
+                    "limit": limit,
+                },
+            ).mappings().all()
+        for raw_row in rows:
+            row = dict(raw_row)
+            row_id = str(row["id"])
+            status = str(row["handoff_status"])
+            invalid_object = False
+            try:
+                self.artifact_store._parse_ref(row["artifact_ref"])
+                stored = self.artifact_store.describe_optional(
+                    row["artifact_ref"]
+                )
+            except Exception:
+                invalid_object = True
+                stored = None
+            if invalid_object:
+                if status == "pending":
+                    with suppress(Exception):
+                        self.artifact_store.delete(row["artifact_ref"])
+                    self._mark_failed(
+                        row_id=row_id,
+                        failure_code="pending_object_invalid",
+                    )
+                else:
+                    self._mark_failed(
+                        row_id=row_id,
+                        failure_code="ready_object_invalid",
                     )
                     )
-        return registered
+                    result["ready_failed"] += 1
+                continue
+            if status == "ready":
+                if stored is None:
+                    self._mark_failed(
+                        row_id=row_id,
+                        failure_code="ready_object_missing",
+                    )
+                    result["ready_failed"] += 1
+                continue
+            if stored is None:
+                with self.engine.begin() as connection:
+                    deleted = connection.execute(
+                        text(
+                            """
+                            DELETE FROM public.rule_run_artifacts
+                            WHERE id = CAST(:id AS uuid)
+                              AND handoff_status = 'pending'
+                            """
+                        ),
+                        {"id": row_id},
+                    )
+                if int(deleted.rowcount or 0) == 1:
+                    result["pending_deleted"] += 1
+                continue
+            if any(
+                (
+                    stored["digest"] != str(row["artifact_digest"]),
+                    stored["row_count"] != int(row["row_count"]),
+                    stored["schema_hash"] != str(row["schema_hash"]),
+                )
+            ):
+                with suppress(Exception):
+                    self.artifact_store.delete(row["artifact_ref"])
+                self._mark_failed(
+                    row_id=row_id,
+                    failure_code="pending_object_invalid",
+                )
+                continue
+            with self.engine.begin() as connection:
+                finalized = connection.execute(
+                    text(
+                        """
+                        UPDATE public.rule_run_artifacts a
+                        SET handoff_status = 'ready',
+                            ready_at = CURRENT_TIMESTAMP,
+                            updated_at = CURRENT_TIMESTAMP,
+                            failure_code = NULL,
+                            failed_at = NULL
+                        WHERE a.id = CAST(:id AS uuid)
+                          AND a.handoff_status = 'pending'
+                          AND EXISTS (
+                              SELECT 1
+                              FROM public.dataflow_dataset_bindings b
+                              WHERE b.id = a.binding_id
+                                AND b.binding_hash = a.binding_hash
+                                AND b.object_kind = 'parquet_artifact'
+                                AND b.access_mode IN (
+                                    'read','write','read_write'
+                                )
+                          )
+                        RETURNING a.id
+                        """
+                    ),
+                    {"id": row_id},
+                )
+            if int(finalized.rowcount or 0) == 1:
+                result["pending_finalized"] += 1
+            else:
+                self._mark_failed(
+                    row_id=row_id,
+                    failure_code="pending_binding_changed",
+                )
+
+        remaining = limit - len(rows)
+        if remaining <= 0:
+            return result
+        now = _now_utc(self.artifact_store.clock)
+        candidates = []
+        scanned = 0
+        for item in self.artifact_store.client.list_objects(
+            self.artifact_store.bucket,
+            prefix="rules/",
+            recursive=True,
+        ):
+            scanned += 1
+            if scanned > limit * 10 or len(candidates) >= remaining:
+                break
+            key = str(getattr(item, "object_name", ""))
+            ref = f"minio://{self.artifact_store.bucket}/{key}"
+            try:
+                self.artifact_store._parse_ref(ref)
+            except ValueError:
+                continue
+            modified = getattr(item, "last_modified", None)
+            if not isinstance(modified, datetime):
+                continue
+            if modified.tzinfo is None:
+                modified = modified.replace(tzinfo=UTC)
+            if modified.astimezone(UTC) > now - timedelta(
+                seconds=grace_seconds
+            ):
+                continue
+            candidates.append(ref)
+        if not candidates:
+            return result
+        with self.engine.connect() as connection:
+            referenced = {
+                str(row["artifact_ref"])
+                for row in connection.execute(
+                    text(
+                        """
+                        SELECT artifact_ref
+                        FROM public.rule_run_artifacts
+                        WHERE artifact_ref =
+                              ANY(CAST(:artifact_refs AS text[]))
+                        """
+                    ),
+                    {"artifact_refs": candidates},
+                ).mappings().all()
+            }
+        for ref in candidates:
+            if ref in referenced:
+                continue
+            self.artifact_store.delete(ref)
+            result["orphans_deleted"] += 1
+        return result
 
 
     def cleanup_expired(self, *, limit: int = 100) -> int:
     def cleanup_expired(self, *, limit: int = 100) -> int:
         if (
         if (

+ 24 - 0
app/runner/bootstrap.py

@@ -81,6 +81,30 @@ class RunnerSettings:
 def register_artifact_cleanup_cli(app, artifact_resolver):
 def register_artifact_cleanup_cli(app, artifact_resolver):
     """Register the bounded production maintenance entrypoint."""
     """Register the bounded production maintenance entrypoint."""
 
 
+    @app.cli.command("reconcile-rule-artifacts")
+    @click.option("--limit", type=click.IntRange(1, 1_000), default=100)
+    @click.option(
+        "--grace-seconds",
+        type=click.IntRange(30, 86_400),
+        default=300,
+    )
+    def reconcile_rule_artifacts(limit, grace_seconds):
+        result = artifact_resolver.reconcile(
+            limit=limit,
+            grace_seconds=grace_seconds,
+        )
+        click.echo(
+            " ".join(
+                f"{key}={result[key]}"
+                for key in (
+                    "pending_finalized",
+                    "pending_deleted",
+                    "ready_failed",
+                    "orphans_deleted",
+                )
+            )
+        )
+
     @app.cli.command("cleanup-rule-artifacts")
     @app.cli.command("cleanup-rule-artifacts")
     @click.option("--limit", type=click.IntRange(1, 1_000), default=100)
     @click.option("--limit", type=click.IntRange(1, 1_000), default=100)
     def cleanup_rule_artifacts(limit):
     def cleanup_rule_artifacts(limit):

+ 39 - 15
app/runner/polars_worker.py

@@ -468,18 +468,23 @@ def _run_process(
     memory_limit_bytes: int,
     memory_limit_bytes: int,
     timeout_seconds: float = 15.0,
     timeout_seconds: float = 15.0,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
-    context = multiprocessing.get_context("spawn")
-    parent, child = context.Pipe(duplex=False)
-    process = context.Process(
-        target=target,
-        args=(child, *args, memory_limit_bytes),
-        daemon=True,
-    )
-    process.start()
-    child.close()
-    deadline = time.monotonic() + timeout_seconds
-    baseline_rss = None
+    parent = None
+    child = None
+    process = None
+    started = False
     try:
     try:
+        context = multiprocessing.get_context("spawn")
+        parent, child = context.Pipe(duplex=False)
+        process = context.Process(
+            target=target,
+            args=(child, *args, memory_limit_bytes),
+            daemon=True,
+        )
+        process.start()
+        started = True
+        child.close()
+        deadline = time.monotonic() + timeout_seconds
+        baseline_rss = None
         while time.monotonic() < deadline:
         while time.monotonic() < deadline:
             if parent.poll(0.01):
             if parent.poll(0.01):
                 try:
                 try:
@@ -523,11 +528,30 @@ def _run_process(
         raise PolarsWorkerResourceError(
         raise PolarsWorkerResourceError(
             "Polars worker exceeded its bounded execution time"
             "Polars worker exceeded its bounded execution time"
         )
         )
+    except (PolarsWorkerError, PolarsWorkerResourceError):
+        raise
+    except Exception as exc:
+        raise PolarsWorkerError(
+            "Polars worker failed to start safely"
+        ) from exc
     finally:
     finally:
-        parent.close()
-        if process.is_alive():
-            process.kill()
-        process.join(timeout=1)
+        if child is not None:
+            with suppress(Exception):
+                child.close()
+        if parent is not None:
+            with suppress(Exception):
+                parent.close()
+        if process is not None:
+            if started:
+                with suppress(Exception):
+                    if process.is_alive():
+                        process.kill()
+                with suppress(Exception):
+                    process.join(timeout=1)
+            close = getattr(process, "close", None)
+            if callable(close):
+                with suppress(Exception):
+                    close()
 
 
 
 
 def run_isolated_memory_probe(
 def run_isolated_memory_probe(

+ 14 - 19
app/runner/rule_polars.py

@@ -12,6 +12,7 @@ from app.core.data_rules.compilers.polars import (
     bound_polars_plan_hash,
     bound_polars_plan_hash,
     validate_bound_polars_plan,
     validate_bound_polars_plan,
 )
 )
+from app.runner.artifacts import ArtifactCommitUnknown
 from app.runner.nodes import NodeExecutionError
 from app.runner.nodes import NodeExecutionError
 from app.runner.polars_worker import (
 from app.runner.polars_worker import (
     PolarsWorkerError,
     PolarsWorkerError,
@@ -232,35 +233,29 @@ class PolarsRulePlanAdapter:
                         "published Polars worker execution failed"
                         "published Polars worker execution failed"
                     ) from exc
                     ) from exc
                 try:
                 try:
-                    artifact = self.artifact_store.write_path(
+                    registered = self.artifact_resolver.publish_path(
                         output_path,
                         output_path,
-                        correlation,
-                        self.artifact_ttl_seconds,
+                        binding_id=normalized["output_binding_id"],
+                        binding_hash=normalized["output_binding_hash"],
+                        correlation_id=correlation,
+                        kind="output",
+                        ttl_seconds=self.artifact_ttl_seconds,
                         schema_fields=normalized["output_fields"],
                         schema_fields=normalized["output_fields"],
                         limits=limits,
                         limits=limits,
                     )
                     )
-                except ValueError as exc:
+                except ArtifactCommitUnknown as exc:
+                    raise NodeExecutionError(
+                        "published Polars artifact commit outcome is unknown",
+                        commit_outcome="unknown",
+                    ) from exc
+                except Exception as exc:
                     raise NodeExecutionError(
                     raise NodeExecutionError(
-                        "published Polars output artifact write failed"
+                        "published Polars output artifact publication failed"
                     ) from exc
                     ) from exc
         finally:
         finally:
             if output_path is not None:
             if output_path is not None:
                 with suppress(FileNotFoundError):
                 with suppress(FileNotFoundError):
                     os.unlink(output_path)
                     os.unlink(output_path)
-        try:
-            registered = self.artifact_resolver.register(
-                binding_id=normalized["output_binding_id"],
-                correlation_id=correlation,
-                artifact=artifact,
-                kind="output",
-                binding_hash=normalized["output_binding_hash"],
-            )
-        except Exception as exc:
-            with suppress(Exception):
-                self.artifact_store.delete(artifact["artifact_ref"])
-            raise NodeExecutionError(
-                "published Polars output artifact registration failed"
-            ) from exc
         return {
         return {
             **{key: registered[key] for key in _PUBLIC_ARTIFACT_KEYS},
             **{key: registered[key] for key in _PUBLIC_ARTIFACT_KEYS},
             **{key: worker_result[key] for key in _RESULT_KEYS},
             **{key: worker_result[key] for key in _RESULT_KEYS},

+ 3 - 5
migrations/versions/20260723_140_rule_run_artifacts.py

@@ -26,7 +26,7 @@ def upgrade() -> None:
                 CHECK (artifact_kind IN ('input','lookup','output')),
                 CHECK (artifact_kind IN ('input','lookup','output')),
             expires_at TIMESTAMPTZ NOT NULL,
             expires_at TIMESTAMPTZ NOT NULL,
             created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
             created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
-            UNIQUE (correlation_id, binding_id, artifact_kind)
+            UNIQUE (correlation_id, binding_id, artifact_digest)
         );
         );
         CREATE INDEX idx_rule_run_artifacts_resolve
         CREATE INDEX idx_rule_run_artifacts_resolve
             ON public.rule_run_artifacts
             ON public.rule_run_artifacts
@@ -36,7 +36,5 @@ def upgrade() -> None:
 
 
 
 
 def downgrade() -> None:
 def downgrade() -> None:
-    raise RuntimeError(
-        "rule run artifact catalog is forward-only and cannot downgrade "
-        "without violating immutable runtime handoff evidence"
-    )
+    # Runtime handoff evidence is immutable and intentionally retained.
+    pass

+ 97 - 0
migrations/versions/20260723_150_rule_artifact_handoff_state.py

@@ -0,0 +1,97 @@
+"""Upgrade artifact handoff from old-140 rows to a durable state machine."""
+
+from alembic import op
+
+revision = "20260723_150"
+down_revision = "20260723_140"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        ALTER TABLE public.rule_run_artifacts
+            ADD COLUMN binding_hash CHAR(64),
+            ADD COLUMN handoff_status VARCHAR(20) NOT NULL
+                DEFAULT 'pending'
+                CHECK (handoff_status IN ('pending','ready','failed')),
+            ADD COLUMN ready_at TIMESTAMPTZ,
+            ADD COLUMN failed_at TIMESTAMPTZ,
+            ADD COLUMN failure_code VARCHAR(100),
+            ADD COLUMN updated_at TIMESTAMPTZ NOT NULL
+                DEFAULT CURRENT_TIMESTAMP;
+
+        UPDATE public.rule_run_artifacts a
+        SET binding_hash = (
+                SELECT binding_hash
+                FROM public.dataflow_dataset_bindings b
+                WHERE b.id = a.binding_id
+            ),
+            handoff_status = 'ready',
+            ready_at = a.created_at,
+            updated_at = a.created_at;
+
+        DO $$
+        BEGIN
+            IF EXISTS (
+                SELECT 1
+                FROM public.rule_run_artifacts
+                WHERE binding_hash IS NULL
+            ) THEN
+                RAISE EXCEPTION
+                    'artifact handoff migration found unattested bindings';
+            END IF;
+            IF EXISTS (
+                SELECT 1
+                FROM public.rule_run_artifacts
+                GROUP BY correlation_id, binding_id, artifact_kind
+                HAVING COUNT(*) > 1
+            ) THEN
+                RAISE EXCEPTION
+                    'artifact handoff migration found conflicting old rows';
+            END IF;
+        END
+        $$;
+
+        ALTER TABLE public.rule_run_artifacts
+            ALTER COLUMN binding_hash SET NOT NULL;
+
+        DO $$
+        DECLARE
+            old_constraint TEXT;
+        BEGIN
+            SELECT c.conname
+            INTO old_constraint
+            FROM pg_constraint c
+            WHERE c.conrelid = 'public.rule_run_artifacts'::regclass
+              AND c.contype = 'u'
+              AND pg_get_constraintdef(c.oid) =
+                  'UNIQUE (correlation_id, binding_id, artifact_digest)';
+            IF old_constraint IS NULL THEN
+                RAISE EXCEPTION
+                    'old-140 artifact uniqueness constraint was not found';
+            END IF;
+            EXECUTE format(
+                'ALTER TABLE public.rule_run_artifacts DROP CONSTRAINT %I',
+                old_constraint
+            );
+        END
+        $$;
+
+        ALTER TABLE public.rule_run_artifacts
+            ADD CONSTRAINT rule_run_artifacts_handoff_key
+            UNIQUE (correlation_id, binding_id, artifact_kind);
+
+        CREATE INDEX idx_rule_run_artifacts_reconcile
+            ON public.rule_run_artifacts
+            (handoff_status, updated_at, id);
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "artifact handoff state is forward-only and cannot downgrade "
+        "without violating durable publication evidence"
+    )

+ 42 - 44
tests/integration/test_data_rule_polars_execution.py

@@ -55,7 +55,7 @@ def _binding(schema, *, source_uid, access_mode, object_ref):
     }
     }
 
 
 
 
-def test_real_postgres_mysql_minio_polars_cross_source_execution():
+def test_real_postgres_mysql_minio_polars_cross_source_execution(tmp_path):
     from app.core.data_rules.compilers.polars import PolarsRuleCompiler
     from app.core.data_rules.compilers.polars import PolarsRuleCompiler
     from app.runner.artifacts import ArtifactStore, PostgresArtifactResolver
     from app.runner.artifacts import ArtifactStore, PostgresArtifactResolver
     from app.runner.rule_polars import PolarsRulePlanAdapter
     from app.runner.rule_polars import PolarsRulePlanAdapter
@@ -483,34 +483,30 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution():
                     "schema_hashes": json.dumps(schema_hashes),
                     "schema_hashes": json.dumps(schema_hashes),
                 },
                 },
             )
             )
-        customer_artifact = store.write(
-            pl.DataFrame(customer_rows),
-            correlation_id,
-            900,
-            schema_fields=input_schema["fields"],
-            limits=compiled["plan"]["resource_limits"],
-        )
-        segment_artifact = store.write(
-            pl.DataFrame(segment_rows),
-            correlation_id,
-            900,
-            schema_fields=lookup_schema["fields"],
-            limits=compiled["plan"]["resource_limits"],
-        )
+        customer_path = tmp_path / "customers.parquet"
+        segment_path = tmp_path / "segments.parquet"
+        pl.DataFrame(customer_rows).write_parquet(customer_path)
+        pl.DataFrame(segment_rows).write_parquet(segment_path)
         resolver = PostgresArtifactResolver(platform, store)
         resolver = PostgresArtifactResolver(platform, store)
-        resolver.register(
+        customer_artifact = resolver.publish_path(
+            str(customer_path),
             binding_id=input_binding["id"],
             binding_id=input_binding["id"],
             binding_hash=compiled["plan"]["input_binding_hash"],
             binding_hash=compiled["plan"]["input_binding_hash"],
             correlation_id=correlation_id,
             correlation_id=correlation_id,
-            artifact=customer_artifact,
             kind="input",
             kind="input",
+            ttl_seconds=900,
+            schema_fields=input_schema["fields"],
+            limits=compiled["plan"]["resource_limits"],
         )
         )
-        resolver.register(
+        segment_artifact = resolver.publish_path(
+            str(segment_path),
             binding_id=lookup_binding["id"],
             binding_id=lookup_binding["id"],
             binding_hash=lookup_operation["lookup_binding_hash"],
             binding_hash=lookup_operation["lookup_binding_hash"],
             correlation_id=correlation_id,
             correlation_id=correlation_id,
-            artifact=segment_artifact,
             kind="lookup",
             kind="lookup",
+            ttl_seconds=900,
+            schema_fields=lookup_schema["fields"],
+            limits=compiled["plan"]["resource_limits"],
         )
         )
         node = {
         node = {
             "id": "task5_real_polars",
             "id": "task5_real_polars",
@@ -641,42 +637,42 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution():
         assert ledger_record is not None
         assert ledger_record is not None
         assert ledger_record.status == "success"
         assert ledger_record.status == "success"
         assert ledger_record.commit_outcome == "committed"
         assert ledger_record.commit_outcome == "committed"
-        conflict = store.write(
-            pl.DataFrame(
-                {
-                    "customer_id": [99],
-                    "mobile": ["13800138000"],
-                    "name": ["Conflict"],
-                    "segment_code": ["A"],
-                    "segment_name": ["Gold"],
-                    "version_no": [1],
-                }
-            ),
-            correlation_id,
-            900,
-            schema_fields=output_schema["fields"],
-            limits=compiled["plan"]["resource_limits"],
+        conflict_path = tmp_path / "conflict.parquet"
+        pl.DataFrame(
+            {
+                "customer_id": [99],
+                "mobile": ["13800138000"],
+                "name": ["Conflict"],
+                "segment_code": ["A"],
+                "segment_name": ["Gold"],
+                "version_no": [1],
+            }
+        ).write_parquet(conflict_path)
+        object_count_before_conflict = len(
+            list(minio.list_objects(bucket, prefix=prefix, recursive=True))
         )
         )
         with pytest.raises(ValueError, match="immutable|digest"):
         with pytest.raises(ValueError, match="immutable|digest"):
-            resolver.register(
+            resolver.publish_path(
+                str(conflict_path),
                 binding_id=output_binding["id"],
                 binding_id=output_binding["id"],
                 binding_hash=compiled["plan"]["output_binding_hash"],
                 binding_hash=compiled["plan"]["output_binding_hash"],
                 correlation_id=correlation_id,
                 correlation_id=correlation_id,
-                artifact=conflict,
                 kind="output",
                 kind="output",
+                ttl_seconds=900,
+                schema_fields=output_schema["fields"],
+                limits=compiled["plan"]["resource_limits"],
             )
             )
-        assert not any(
-            item.object_name
-            == conflict["artifact_ref"].split("/", 3)[-1]
-            for item in minio.list_objects(
-                bucket, prefix=prefix, recursive=True
-            )
-        )
+        assert len(
+            list(minio.list_objects(bucket, prefix=prefix, recursive=True))
+        ) == object_count_before_conflict
+        assert customer_artifact["digest"]
+        assert segment_artifact["digest"]
         with platform.connect() as connection:
         with platform.connect() as connection:
             catalog_rows = connection.execute(
             catalog_rows = connection.execute(
                 text(
                 text(
                     """
                     """
-                    SELECT artifact_kind, artifact_ref
+                    SELECT artifact_kind, artifact_ref, handoff_status,
+                           binding_hash
                     FROM public.rule_run_artifacts
                     FROM public.rule_run_artifacts
                     WHERE correlation_id = CAST(:correlation_id AS uuid)
                     WHERE correlation_id = CAST(:correlation_id AS uuid)
                     ORDER BY artifact_kind
                     ORDER BY artifact_kind
@@ -687,6 +683,8 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution():
         # The repeated deterministic output has the same digest and is
         # The repeated deterministic output has the same digest and is
         # idempotently retained as one stable catalog handoff.
         # idempotently retained as one stable catalog handoff.
         assert len(catalog_rows) == 3
         assert len(catalog_rows) == 3
+        assert all(row["handoff_status"] == "ready" for row in catalog_rows)
+        assert all(len(row["binding_hash"]) == 64 for row in catalog_rows)
         assert next(
         assert next(
             row["artifact_ref"]
             row["artifact_ref"]
             for row in catalog_rows
             for row in catalog_rows

+ 330 - 0
tests/integration/test_rule_artifact_migration_upgrade.py

@@ -0,0 +1,330 @@
+from __future__ import annotations
+
+import logging
+import os
+import re
+from pathlib import Path
+
+import polars as pl
+import pytest
+from alembic import command
+from alembic.config import Config
+from sqlalchemy import create_engine, text
+from sqlalchemy.exc import IntegrityError
+
+from app.core.common.identifiers import new_governance_uid
+from tests.runner.test_artifacts import FakeMinio, _store
+
+ROOT = Path(__file__).resolve().parents[2]
+COMPOSE = ROOT / "deploy" / "docker" / "docker-compose.yml"
+
+
+def _compose_value(pattern: str) -> str:
+    match = re.search(
+        pattern,
+        COMPOSE.read_text(encoding="utf-8"),
+        flags=re.DOTALL,
+    )
+    assert match is not None
+    return match.group(1)
+
+
+def _upgrade(database_url: str, revision: str) -> None:
+    previous = os.environ.get("DATABASE_URL")
+    root_logger = logging.getLogger()
+    root_handlers = list(root_logger.handlers)
+    root_level = root_logger.level
+    logger_disabled = {
+        name: logger.disabled
+        for name, logger in logging.Logger.manager.loggerDict.items()
+        if isinstance(logger, logging.Logger)
+    }
+    os.environ["DATABASE_URL"] = database_url
+    try:
+        command.upgrade(Config(str(ROOT / "alembic.ini")), revision)
+    finally:
+        root_logger.handlers[:] = root_handlers
+        root_logger.setLevel(root_level)
+        for name, disabled in logger_disabled.items():
+            logging.getLogger(name).disabled = disabled
+        if previous is None:
+            os.environ.pop("DATABASE_URL", None)
+        else:
+            os.environ["DATABASE_URL"] = previous
+
+
+def test_old_140_upgrades_to_durable_handoff_and_enforces_cas(tmp_path):
+    from app.runner.artifacts import PostgresArtifactResolver
+
+    platform_user = _compose_value(
+        r"\n  postgres:.*?POSTGRES_USER:\s*([^\s]+)"
+    )
+    platform_password = _compose_value(
+        r"\n  postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
+    )
+    platform_port = _compose_value(r'"(15432):5432"')
+    admin_url = (
+        f"postgresql+psycopg2://{platform_user}:{platform_password}"
+        f"@127.0.0.1:{platform_port}/postgres"
+    )
+    database_name = f"task5_migration_{new_governance_uid().replace('-', '')}"
+    database_url = (
+        f"postgresql+psycopg2://{platform_user}:{platform_password}"
+        f"@127.0.0.1:{platform_port}/{database_name}"
+    )
+    admin = create_engine(admin_url, isolation_level="AUTOCOMMIT")
+    engine = None
+    try:
+        with admin.connect() as connection:
+            connection.execute(text(f'CREATE DATABASE "{database_name}"'))
+        _upgrade(database_url, "20260723_140")
+        engine = create_engine(database_url, pool_pre_ping=True)
+        dataflow_version_id = new_governance_uid()
+        deployment_id = new_governance_uid()
+        schema_id = new_governance_uid()
+        binding_id = new_governance_uid()
+        binding_hash = "b" * 64
+        correlation_id = new_governance_uid()
+        old_artifact_id = new_governance_uid()
+        old_ref = (
+            f"minio://dataops-rules/rules/{correlation_id}/"
+            f"{new_governance_uid()}.parquet"
+        )
+        with engine.begin() as connection:
+            columns_before = {
+                row[0]
+                for row in connection.execute(
+                    text(
+                        """
+                        SELECT column_name
+                        FROM information_schema.columns
+                        WHERE table_schema = 'public'
+                          AND table_name = 'rule_run_artifacts'
+                        """
+                    )
+                )
+            }
+            assert "handoff_status" not in columns_before
+            connection.execute(
+                text(
+                    """
+                    INSERT INTO public.dataflow_versions (
+                        id, dataflow_uid, version_no, name, dataflow_spec,
+                        input_schema_hashes, output_schema_hash, status
+                    ) VALUES (
+                        CAST(:id AS uuid), CAST(:uid AS uuid), 1, 'migration',
+                        '{}'::jsonb, '[]'::jsonb, :schema_hash, 'released'
+                    )
+                    """
+                ),
+                {
+                    "id": dataflow_version_id,
+                    "uid": new_governance_uid(),
+                    "schema_hash": "a" * 64,
+                },
+            )
+            connection.execute(
+                text(
+                    """
+                    INSERT INTO public.dataflow_deployments (
+                        id, dataflow_version_id, environment,
+                        deployment_config, status
+                    ) VALUES (
+                        CAST(:id AS uuid), CAST(:version_id AS uuid), 'test',
+                        '{}'::jsonb, 'active'
+                    )
+                    """
+                ),
+                {"id": deployment_id, "version_id": dataflow_version_id},
+            )
+            connection.execute(
+                text(
+                    """
+                    INSERT INTO public.data_schema_snapshots (
+                        id, schema_ref, schema_hash, fields, source_revision
+                    ) VALUES (
+                        CAST(:id AS uuid), 'migration:id', :schema_hash,
+                        CAST(:fields AS jsonb), 'old-140'
+                    )
+                    """
+                ),
+                {
+                    "id": schema_id,
+                    "schema_hash": "a" * 64,
+                    "fields": (
+                        '[{"name":"id","type":"integer",'
+                        '"nullable":false}]'
+                    ),
+                },
+            )
+            connection.execute(
+                text(
+                    """
+                    INSERT INTO public.dataflow_dataset_bindings (
+                        id, dataflow_deployment_id, logical_ref,
+                        object_kind, object_ref, schema_snapshot_id, dialect,
+                        access_mode, write_mode, binding_hash
+                    ) VALUES (
+                        CAST(:id AS uuid), CAST(:deployment_id AS uuid),
+                        'output', 'parquet_artifact', 'migration-output',
+                        CAST(:schema_id AS uuid), 'parquet', 'write',
+                        'append', :binding_hash
+                    )
+                    """
+                ),
+                {
+                    "id": binding_id,
+                    "deployment_id": deployment_id,
+                    "schema_id": schema_id,
+                    "binding_hash": binding_hash,
+                },
+            )
+            connection.execute(
+                text(
+                    """
+                    INSERT INTO public.rule_run_artifacts (
+                        id, correlation_id, binding_id, artifact_ref,
+                        artifact_digest, row_count, schema_hash, schema_fields,
+                        artifact_kind, expires_at
+                    ) VALUES (
+                        CAST(:id AS uuid), CAST(:correlation_id AS uuid),
+                        CAST(:binding_id AS uuid), :artifact_ref,
+                        :digest, 1, :schema_hash, CAST(:fields AS jsonb),
+                        'output', CURRENT_TIMESTAMP + INTERVAL '1 hour'
+                    )
+                    """
+                ),
+                {
+                    "id": old_artifact_id,
+                    "correlation_id": correlation_id,
+                    "binding_id": binding_id,
+                    "artifact_ref": old_ref,
+                    "digest": "d" * 64,
+                    "schema_hash": "a" * 64,
+                    "fields": (
+                        '[{"name":"id","type":"integer",'
+                        '"nullable":false}]'
+                    ),
+                },
+            )
+
+        engine.dispose()
+        engine = None
+        _upgrade(database_url, "head")
+        engine = create_engine(database_url, pool_pre_ping=True)
+        with engine.begin() as connection:
+            migrated = connection.execute(
+                text(
+                    """
+                    SELECT binding_hash, handoff_status, ready_at
+                    FROM public.rule_run_artifacts
+                    WHERE id = CAST(:id AS uuid)
+                    """
+                ),
+                {"id": old_artifact_id},
+            ).mappings().one()
+            assert migrated["binding_hash"] == binding_hash
+            assert migrated["handoff_status"] == "ready"
+            assert migrated["ready_at"] is not None
+            connection.execute(
+                text(
+                    """
+                    DELETE FROM public.rule_run_artifacts
+                    WHERE id = CAST(:id AS uuid)
+                    """
+                ),
+                {"id": old_artifact_id},
+            )
+
+        store = _store(FakeMinio())
+        resolver = PostgresArtifactResolver(engine, store)
+        schema_fields = [
+            {"name": "id", "type": "integer", "nullable": False}
+        ]
+        same_path = tmp_path / "same.parquet"
+        conflict_path = tmp_path / "conflict.parquet"
+        pl.DataFrame({"id": [1]}).write_parquet(same_path)
+        pl.DataFrame({"id": [2]}).write_parquet(conflict_path)
+        first = resolver.publish_path(
+            str(same_path),
+            binding_id=binding_id,
+            binding_hash=binding_hash,
+            correlation_id=correlation_id,
+            kind="output",
+            ttl_seconds=300,
+            schema_fields=schema_fields,
+        )
+        repeated = resolver.publish_path(
+            str(same_path),
+            binding_id=binding_id,
+            binding_hash=binding_hash,
+            correlation_id=correlation_id,
+            kind="output",
+            ttl_seconds=300,
+            schema_fields=schema_fields,
+        )
+        assert repeated["artifact_ref"] == first["artifact_ref"]
+        assert len(store.client.objects) == 1
+        with pytest.raises(ValueError, match="immutable|digest"):
+            resolver.publish_path(
+                str(conflict_path),
+                binding_id=binding_id,
+                binding_hash=binding_hash,
+                correlation_id=correlation_id,
+                kind="output",
+                ttl_seconds=300,
+                schema_fields=schema_fields,
+            )
+        assert len(store.client.objects) == 1
+
+        with pytest.raises(IntegrityError), engine.begin() as connection:
+            connection.execute(
+                text(
+                    """
+                    INSERT INTO public.rule_run_artifacts (
+                        id, correlation_id, binding_id, artifact_ref,
+                        artifact_digest, row_count, schema_hash,
+                        schema_fields, artifact_kind, binding_hash,
+                        handoff_status, expires_at
+                    ) VALUES (
+                        CAST(:id AS uuid),
+                        CAST(:correlation_id AS uuid),
+                        CAST(:binding_id AS uuid), :artifact_ref,
+                        :artifact_digest, 1, :schema_hash,
+                        CAST(:fields AS jsonb), 'output', :binding_hash,
+                        'pending',
+                        CURRENT_TIMESTAMP + INTERVAL '5 minutes'
+                    )
+                    """
+                ),
+                {
+                    "id": new_governance_uid(),
+                    "correlation_id": correlation_id,
+                    "binding_id": binding_id,
+                    "artifact_ref": old_ref,
+                    "artifact_digest": "e" * 64,
+                    "schema_hash": "a" * 64,
+                    "fields": (
+                        '[{"name":"id","type":"integer",'
+                        '"nullable":false}]'
+                    ),
+                    "binding_hash": binding_hash,
+                },
+            )
+    finally:
+        if engine is not None:
+            engine.dispose()
+        with admin.connect() as connection:
+            connection.execute(
+                text(
+                    """
+                    SELECT pg_terminate_backend(pid)
+                    FROM pg_stat_activity
+                    WHERE datname = :database_name
+                      AND pid <> pg_backend_pid()
+                    """
+                ),
+                {"database_name": database_name},
+            )
+            connection.execute(text(f'DROP DATABASE IF EXISTS "{database_name}"'))
+        admin.dispose()

+ 475 - 0
tests/runner/test_artifact_handoff.py

@@ -0,0 +1,475 @@
+from __future__ import annotations
+
+import copy
+import json
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+
+import polars as pl
+import pytest
+
+from app.core.common.identifiers import new_governance_uid
+from tests.runner.test_artifacts import FakeMinio, _store
+
+
+class _Result:
+    def __init__(self, row=None, *, rowcount=0):
+        self.row = row
+        self.rowcount = rowcount
+
+    def mappings(self):
+        return self
+
+    def one_or_none(self):
+        return self.row
+
+    def all(self):
+        if self.row is None:
+            return []
+        return self.row if isinstance(self.row, list) else [self.row]
+
+
+class _HandoffConnection:
+    def __init__(self, engine, *, transactional):
+        self.engine = engine
+        self.transactional = transactional
+        self.backup = None
+        self.finalized = False
+
+    def __enter__(self):
+        self.backup = copy.deepcopy(self.engine.row)
+        return self
+
+    def __exit__(self, exc_type, *_args):
+        if exc_type is not None:
+            self.engine.row = self.backup
+            return False
+        if self.finalized and self.engine.finalize_mode:
+            if self.engine.finalize_mode == "rollback_unknown":
+                self.engine.row = self.backup
+                self.engine.fail_recheck = True
+            raise RuntimeError("database commit acknowledgement lost")
+        return False
+
+    def execute(self, statement, parameters):
+        sql = str(statement)
+        self.engine.events.append(sql)
+        if (
+            "FROM public.dataflow_dataset_bindings" in sql
+            and "JOIN" not in sql
+        ):
+            if self.engine.fail_reserve:
+                raise RuntimeError("database unavailable")
+            return _Result(copy.deepcopy(self.engine.binding))
+        if "INSERT INTO public.rule_run_artifacts" in sql:
+            if self.engine.row is not None:
+                return _Result(None)
+            self.engine.row = {
+                "id": parameters["id"],
+                "correlation_id": parameters["correlation_id"],
+                "binding_id": parameters["binding_id"],
+                "artifact_ref": parameters["artifact_ref"],
+                "artifact_digest": parameters["artifact_digest"],
+                "row_count": parameters["row_count"],
+                "schema_hash": parameters["schema_hash"],
+                "schema_fields": json.loads(parameters["schema_fields"]),
+                "artifact_kind": parameters["artifact_kind"],
+                "binding_hash": parameters["binding_hash"],
+                "expires_at": parameters["expires_at"],
+                "handoff_status": "pending",
+            }
+            return _Result(copy.deepcopy(self.engine.row), rowcount=1)
+        if (
+            "UPDATE public.rule_run_artifacts" in sql
+            and "handoff_status = 'ready'" in sql
+        ):
+            if self.engine.row is not None:
+                self.engine.row["handoff_status"] = "ready"
+                self.finalized = True
+                return _Result(copy.deepcopy(self.engine.row), rowcount=1)
+            return _Result(None)
+        if (
+            "DELETE FROM public.rule_run_artifacts" in sql
+            and "handoff_status = 'pending'" in sql
+        ):
+            deleted = self.engine.row is not None
+            self.engine.row = None
+            return _Result(rowcount=int(deleted))
+        if "FROM public.rule_run_artifacts" in sql:
+            if self.engine.fail_recheck:
+                raise RuntimeError("database recheck unavailable")
+            row = copy.deepcopy(self.engine.row)
+            if "JOIN public.dataflow_dataset_bindings" in sql:
+                if (
+                    row is None
+                    or row["handoff_status"] != "ready"
+                    or row["binding_hash"] != self.engine.binding[
+                        "binding_hash"
+                    ]
+                ):
+                    row = None
+                elif row is not None:
+                    row["current_binding_hash"] = self.engine.binding[
+                        "binding_hash"
+                    ]
+            return _Result(row)
+        raise AssertionError(sql)
+
+
+class HandoffEngine:
+    def __init__(self, binding):
+        self.binding = dict(binding)
+        self.row = None
+        self.events = []
+        self.fail_reserve = False
+        self.fail_recheck = False
+        self.finalize_mode = None
+
+    def begin(self):
+        return _HandoffConnection(self, transactional=True)
+
+    def connect(self):
+        return _HandoffConnection(self, transactional=False)
+
+
+class EventMinio(FakeMinio):
+    def __init__(self, events):
+        super().__init__()
+        self.events = events
+        self.fail_upload = False
+
+    def put_object(self, *args, **kwargs):
+        self.events.append("MINIO PUT")
+        if self.fail_upload:
+            raise RuntimeError("object upload failed")
+        return super().put_object(*args, **kwargs)
+
+
+def _binding(binding_hash):
+    return {
+        "binding_hash": binding_hash,
+        "access_mode": "read_write",
+        "object_kind": "parquet_artifact",
+    }
+
+
+def _publish_fixture(tmp_path, *, engine=None, client=None):
+    from app.runner.artifacts import PostgresArtifactResolver
+
+    binding_id = new_governance_uid()
+    binding_hash = "b" * 64
+    engine = engine or HandoffEngine(_binding(binding_hash))
+    client = client or EventMinio(engine.events)
+    store = _store(client)
+    path = tmp_path / "handoff.parquet"
+    pl.DataFrame({"id": [1]}).write_parquet(path)
+    resolver = PostgresArtifactResolver(engine, store)
+    return {
+        "binding_id": binding_id,
+        "binding_hash": binding_hash,
+        "client": client,
+        "engine": engine,
+        "path": path,
+        "resolver": resolver,
+        "store": store,
+    }
+
+
+def _publish(fixture):
+    return fixture["resolver"].publish_path(
+        str(fixture["path"]),
+        binding_id=fixture["binding_id"],
+        binding_hash=fixture["binding_hash"],
+        correlation_id=new_governance_uid(),
+        kind="output",
+        ttl_seconds=60,
+        schema_fields=[
+            {"name": "id", "type": "integer", "nullable": False}
+        ],
+    )
+
+
+def test_publish_reserves_pending_before_upload_and_finalizes_ready(tmp_path):
+    fixture = _publish_fixture(tmp_path)
+
+    result = _publish(fixture)
+
+    events = fixture["engine"].events
+    assert next(
+        index
+        for index, event in enumerate(events)
+        if "INSERT INTO public.rule_run_artifacts" in event
+    ) < events.index("MINIO PUT")
+    assert events.index("MINIO PUT") < next(
+        index
+        for index, event in enumerate(events)
+        if "handoff_status = 'ready'" in event
+    )
+    assert fixture["engine"].row["handoff_status"] == "ready"
+    assert fixture["engine"].row["binding_hash"] == fixture["binding_hash"]
+    assert result["artifact_ref"] == fixture["engine"].row["artifact_ref"]
+
+
+def test_reserve_locks_and_rejects_stale_binding_before_upload(tmp_path):
+    fixture = _publish_fixture(tmp_path)
+    fixture["engine"].binding["binding_hash"] = "c" * 64
+
+    with pytest.raises(ValueError, match="binding"):
+        _publish(fixture)
+
+    assert any(
+        "FOR SHARE" in event
+        for event in fixture["engine"].events
+        if "dataflow_dataset_bindings" in event
+    )
+    assert "MINIO PUT" not in fixture["engine"].events
+    assert fixture["engine"].row is None
+
+
+def test_reserve_crash_never_uploads_an_object(tmp_path):
+    from app.runner.artifacts import ArtifactCommitUnknown
+
+    fixture = _publish_fixture(tmp_path)
+    fixture["engine"].fail_reserve = True
+
+    with pytest.raises(ArtifactCommitUnknown):
+        _publish(fixture)
+
+    assert "MINIO PUT" not in fixture["engine"].events
+    assert fixture["client"].objects == {}
+
+
+def test_upload_failure_removes_pending_reservation(tmp_path):
+    fixture = _publish_fixture(tmp_path)
+    fixture["client"].fail_upload = True
+
+    with pytest.raises(RuntimeError, match="upload"):
+        _publish(fixture)
+
+    assert fixture["engine"].row is None
+    assert fixture["client"].objects == {}
+
+
+def test_finalize_commit_after_client_error_is_rechecked_as_success(tmp_path):
+    fixture = _publish_fixture(tmp_path)
+    fixture["engine"].finalize_mode = "committed_then_error"
+
+    result = _publish(fixture)
+
+    assert fixture["engine"].row["handoff_status"] == "ready"
+    assert result["artifact_ref"] == fixture["engine"].row["artifact_ref"]
+    assert fixture["client"].objects
+
+
+def test_unconfirmed_finalize_returns_unknown_without_deleting_object(tmp_path):
+    from app.runner.artifacts import ArtifactCommitUnknown
+
+    fixture = _publish_fixture(tmp_path)
+    fixture["engine"].finalize_mode = "rollback_unknown"
+
+    with pytest.raises(ArtifactCommitUnknown):
+        _publish(fixture)
+
+    assert fixture["client"].objects
+
+
+class ReconcileMinio(FakeMinio):
+    def __init__(self, now):
+        super().__init__()
+        self.now = now
+        self.ages = {}
+
+    def list_objects(self, bucket, *, prefix, recursive):
+        assert recursive is True
+        return [
+            SimpleNamespace(
+                object_name=key,
+                last_modified=self.ages.get(key, self.now),
+            )
+            for object_bucket, key in sorted(self.objects)
+            if object_bucket == bucket and key.startswith(prefix)
+        ]
+
+
+class ReconcileEngine:
+    def __init__(self, rows):
+        self.rows = [dict(row) for row in rows]
+        self.events = []
+
+    def begin(self):
+        return _ReconcileConnection(self)
+
+    def connect(self):
+        return _ReconcileConnection(self)
+
+
+class _ReconcileConnection:
+    def __init__(self, engine):
+        self.engine = engine
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, *_args):
+        return False
+
+    def execute(self, statement, parameters):
+        sql = str(statement)
+        self.engine.events.append(sql)
+        if (
+            "SELECT id::text AS id" in sql
+            and "handoff_status IN" in sql
+        ):
+            return _Result(
+                copy.deepcopy(self.engine.rows[: parameters["limit"]])
+            )
+        if "SELECT artifact_ref" in sql and "handoff_status" not in sql:
+            return _Result(
+                [{"artifact_ref": row["artifact_ref"]} for row in self.engine.rows]
+            )
+        row = next(
+            (
+                item
+                for item in self.engine.rows
+                if item["id"] == parameters.get("id")
+            ),
+            None,
+        )
+        if (
+            "UPDATE public.rule_run_artifacts" in sql
+            and "handoff_status = 'ready'" in sql
+        ):
+            if row is not None:
+                row["handoff_status"] = "ready"
+            return _Result(copy.deepcopy(row), rowcount=int(row is not None))
+        if (
+            "UPDATE public.rule_run_artifacts" in sql
+            and "handoff_status = 'failed'" in sql
+        ):
+            if row is not None:
+                row["handoff_status"] = "failed"
+                row["failure_code"] = parameters["failure_code"]
+            return _Result(rowcount=int(row is not None))
+        if "DELETE FROM public.rule_run_artifacts" in sql:
+            before = len(self.engine.rows)
+            self.engine.rows = [
+                item
+                for item in self.engine.rows
+                if item["id"] != parameters["id"]
+            ]
+            return _Result(rowcount=before - len(self.engine.rows))
+        raise AssertionError(sql)
+
+
+def _catalog_row(artifact, *, status, binding_hash="d" * 64):
+    return {
+        "id": new_governance_uid(),
+        "artifact_ref": artifact["artifact_ref"],
+        "artifact_digest": artifact["digest"],
+        "row_count": artifact["row_count"],
+        "schema_hash": artifact["schema_hash"],
+        "schema_fields": artifact["schema_fields"],
+        "expires_at": artifact["expires_at"],
+        "handoff_status": status,
+        "binding_hash": binding_hash,
+        "binding_id": new_governance_uid(),
+        "artifact_kind": "output",
+        "correlation_id": artifact["artifact_ref"].split("/")[4],
+    }
+
+
+def test_reconcile_repairs_both_catalog_and_object_store_safely():
+    from app.runner.artifacts import PostgresArtifactResolver
+
+    now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
+    client = ReconcileMinio(now)
+    store = _store(client, clock=lambda: now)
+    valid_pending = store.write(
+        pl.DataFrame({"id": [1]}),
+        new_governance_uid(),
+        600,
+    )
+    missing_pending = store.write(
+        pl.DataFrame({"id": [2]}),
+        new_governance_uid(),
+        600,
+    )
+    store.delete(missing_pending["artifact_ref"])
+    missing_ready = store.write(
+        pl.DataFrame({"id": [3]}),
+        new_governance_uid(),
+        600,
+    )
+    store.delete(missing_ready["artifact_ref"])
+    invalid_pending = store.write(
+        pl.DataFrame({"id": [6]}),
+        new_governance_uid(),
+        600,
+    )
+    invalid_key = invalid_pending["artifact_ref"].split("/", 3)[-1]
+    client.objects[("dataops-rules", invalid_key)][
+        "content_type"
+    ] = "text/plain"
+    old_orphan = store.write(
+        pl.DataFrame({"id": [4]}),
+        new_governance_uid(),
+        600,
+    )
+    fresh_orphan = store.write(
+        pl.DataFrame({"id": [5]}),
+        new_governance_uid(),
+        600,
+    )
+    old_key = old_orphan["artifact_ref"].split("/", 3)[-1]
+    fresh_key = fresh_orphan["artifact_ref"].split("/", 3)[-1]
+    client.ages[old_key] = now - timedelta(minutes=20)
+    client.ages[fresh_key] = now - timedelta(seconds=30)
+    client.objects[
+        ("dataops-rules", "rules/not-a-safe-catalog-key.parquet")
+    ] = copy.deepcopy(next(iter(client.objects.values())))
+    client.ages["rules/not-a-safe-catalog-key.parquet"] = now - timedelta(
+        hours=1
+    )
+    engine = ReconcileEngine(
+        [
+            _catalog_row(valid_pending, status="pending"),
+            _catalog_row(missing_pending, status="pending"),
+            _catalog_row(missing_ready, status="ready"),
+            _catalog_row(invalid_pending, status="pending"),
+        ]
+    )
+
+    result = PostgresArtifactResolver(engine, store).reconcile(
+        limit=10,
+        grace_seconds=300,
+    )
+
+    assert result == {
+        "pending_finalized": 1,
+        "pending_deleted": 1,
+        "ready_failed": 1,
+        "orphans_deleted": 1,
+    }
+    assert ("dataops-rules", old_key) not in client.objects
+    assert ("dataops-rules", fresh_key) in client.objects
+    assert ("dataops-rules", invalid_key) not in client.objects
+    assert (
+        "dataops-rules",
+        "rules/not-a-safe-catalog-key.parquet",
+    ) in client.objects
+    assert any(
+        row["handoff_status"] == "ready"
+        and row["artifact_ref"] == valid_pending["artifact_ref"]
+        for row in engine.rows
+    )
+    assert any(
+        row["handoff_status"] == "failed"
+        and row["artifact_ref"] == missing_ready["artifact_ref"]
+        for row in engine.rows
+    )
+    assert any(
+        row["handoff_status"] == "failed"
+        and row["artifact_ref"] == invalid_pending["artifact_ref"]
+        and row["failure_code"] == "pending_object_invalid"
+        for row in engine.rows
+    )

+ 32 - 156
tests/runner/test_artifacts.py

@@ -1,7 +1,6 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import io
 import io
-import json
 from datetime import UTC, datetime
 from datetime import UTC, datetime
 from types import SimpleNamespace
 from types import SimpleNamespace
 
 
@@ -341,6 +340,34 @@ def test_artifact_stage_rejects_compression_bomb_from_footer_before_scan():
     assert len(client.get_calls) == calls_before + 1
     assert len(client.get_calls) == calls_before + 1
 
 
 
 
+def test_artifact_prepare_reserves_key_before_exact_path_upload(tmp_path):
+    client = FakeMinio()
+    store = _store(client)
+    correlation_id = new_governance_uid()
+    path = tmp_path / "prepared.parquet"
+    pl.DataFrame({"id": [1, 2]}).write_parquet(path)
+    fields = [{"name": "id", "type": "integer", "nullable": False}]
+
+    prepared = store.prepare_path(
+        str(path),
+        correlation_id,
+        60,
+        schema_fields=fields,
+    )
+
+    assert client.objects == {}
+    assert prepared["artifact_ref"].startswith(
+        f"minio://dataops-rules/rules/{correlation_id}/"
+    )
+
+    uploaded = store.upload_path(str(path), prepared)
+
+    assert uploaded == prepared
+    assert store.describe(prepared["artifact_ref"])["digest"] == prepared[
+        "digest"
+    ]
+
+
 def test_artifact_schema_contract_covers_nullability_decimal_and_timezone():
 def test_artifact_schema_contract_covers_nullability_decimal_and_timezone():
     client = FakeMinio()
     client = FakeMinio()
     store = _store(client)
     store = _store(client)
@@ -494,7 +521,8 @@ def test_postgres_artifact_resolver_uses_catalog_and_rechecks_binding_hash():
             return {
             return {
                 **artifact,
                 **artifact,
                 "artifact_digest": artifact["digest"],
                 "artifact_digest": artifact["digest"],
-                "binding_hash": binding_hash,
+                "catalog_binding_hash": binding_hash,
+                "current_binding_hash": binding_hash,
             }
             }
         raise AssertionError(sql)
         raise AssertionError(sql)
 
 
@@ -511,6 +539,8 @@ def test_postgres_artifact_resolver_uses_catalog_and_rechecks_binding_hash():
     assert "a.correlation_id = CAST(:correlation_id AS uuid)" in (
     assert "a.correlation_id = CAST(:correlation_id AS uuid)" in (
         engine.calls[0][0]
         engine.calls[0][0]
     )
     )
+    assert "a.handoff_status = 'ready'" in engine.calls[0][0]
+    assert "a.binding_hash = b.binding_hash" in engine.calls[0][0]
 
 
     with pytest.raises(ValueError, match="correlation"):
     with pytest.raises(ValueError, match="correlation"):
         PostgresArtifactResolver(engine, store).resolve(
         PostgresArtifactResolver(engine, store).resolve(
@@ -520,160 +550,6 @@ def test_postgres_artifact_resolver_uses_catalog_and_rechecks_binding_hash():
         )
         )
 
 
 
 
-def test_postgres_artifact_resolver_attests_and_registers_stable_handoff():
-    from app.runner.artifacts import PostgresArtifactResolver
-
-    store = _store(FakeMinio())
-    correlation_id = new_governance_uid()
-    binding_id = new_governance_uid()
-    binding_hash = "b" * 64
-    artifact = store.write(
-        pl.DataFrame({"id": [1]}),
-        correlation_id,
-        60,
-    )
-
-    def handler(sql, parameters):
-        if "FROM public.dataflow_dataset_bindings" in sql:
-            return {
-                "binding_hash": binding_hash,
-                "access_mode": "read_write",
-                "object_kind": "parquet_artifact",
-            }
-        if "INSERT INTO public.rule_run_artifacts" in sql:
-            assert parameters["artifact_digest"] == artifact["digest"]
-            assert parameters["schema_fields"]
-            return {
-                "artifact_ref": parameters["artifact_ref"],
-                "artifact_digest": parameters["artifact_digest"],
-                "row_count": parameters["row_count"],
-                "schema_hash": parameters["schema_hash"],
-                "schema_fields": json.loads(parameters["schema_fields"]),
-                "expires_at": parameters["expires_at"],
-            }
-        raise AssertionError(sql)
-
-    engine = _Engine(handler)
-    resolver = PostgresArtifactResolver(engine, store)
-    resolver.attest_binding(
-        binding_id=binding_id,
-        binding_hash=binding_hash,
-        access_mode="write",
-    )
-    resolver.register(
-        binding_id=binding_id,
-        correlation_id=correlation_id,
-        artifact=artifact,
-        kind="output",
-        binding_hash=binding_hash,
-    )
-
-    assert sum(
-        "FROM public.dataflow_dataset_bindings" in sql
-        for sql, _parameters in engine.calls
-    ) == 2
-    assert any(
-        "INSERT INTO public.rule_run_artifacts" in sql
-        for sql, _parameters in engine.calls
-    )
-
-
-def test_artifact_catalog_reuses_same_digest_and_rejects_digest_conflict():
-    from app.runner.artifacts import PostgresArtifactResolver
-
-    store = _store(FakeMinio())
-    correlation_id = new_governance_uid()
-    binding_id = new_governance_uid()
-    binding_hash = "c" * 64
-    catalog = {}
-
-    def handler(sql, parameters):
-        if "FROM public.dataflow_dataset_bindings" in sql:
-            return {
-                "binding_hash": binding_hash,
-                "access_mode": "read_write",
-                "object_kind": "parquet_artifact",
-            }
-        if "INSERT INTO public.rule_run_artifacts" in sql:
-            key = (
-                parameters["correlation_id"],
-                parameters["binding_id"],
-                parameters["artifact_kind"],
-            )
-            if key in catalog:
-                return None
-            catalog[key] = {
-                "artifact_ref": parameters["artifact_ref"],
-                "artifact_digest": parameters["artifact_digest"],
-                "row_count": parameters["row_count"],
-                "schema_hash": parameters["schema_hash"],
-                "schema_fields": json.loads(parameters["schema_fields"]),
-                "expires_at": parameters["expires_at"],
-            }
-            return catalog[key]
-        if "FROM public.rule_run_artifacts" in sql:
-            key = (
-                parameters["correlation_id"],
-                parameters["binding_id"],
-                parameters["artifact_kind"],
-            )
-            return catalog.get(key)
-        raise AssertionError(sql)
-
-    engine = _Engine(handler)
-    resolver = PostgresArtifactResolver(engine, store)
-    first = store.write(
-        pl.DataFrame({"id": [1]}),
-        correlation_id,
-        60,
-    )
-    first_registered = resolver.register(
-        binding_id=binding_id,
-        correlation_id=correlation_id,
-        artifact=first,
-        kind="output",
-        binding_hash=binding_hash,
-    )
-    retry = store.write(
-        pl.DataFrame({"id": [1]}),
-        correlation_id,
-        60,
-    )
-    retry_registered = resolver.register(
-        binding_id=binding_id,
-        correlation_id=correlation_id,
-        artifact=retry,
-        kind="output",
-        binding_hash=binding_hash,
-    )
-
-    assert retry_registered["artifact_ref"] == first_registered["artifact_ref"]
-    assert retry["artifact_ref"] != first_registered["artifact_ref"]
-    assert (
-        "dataops-rules",
-        retry["artifact_ref"].split("/", 3)[-1],
-    ) in store.client.removed
-
-    conflict = store.write(
-        pl.DataFrame({"id": [2]}),
-        correlation_id,
-        60,
-    )
-    with pytest.raises(ValueError, match="immutable|digest"):
-        resolver.register(
-            binding_id=binding_id,
-            correlation_id=correlation_id,
-            artifact=conflict,
-            kind="output",
-            binding_hash=binding_hash,
-        )
-    assert (
-        "dataops-rules",
-        conflict["artifact_ref"].split("/", 3)[-1],
-    ) in store.client.removed
-    assert len(catalog) == 1
-
-
 def test_catalog_cleanup_deletes_expired_object_and_directory_row():
 def test_catalog_cleanup_deletes_expired_object_and_directory_row():
     from app.runner.artifacts import PostgresArtifactResolver
     from app.runner.artifacts import PostgresArtifactResolver
 
 

+ 20 - 5
tests/runner/test_bootstrap.py

@@ -51,23 +51,38 @@ def test_runner_refuses_to_start_without_a_task_token_secret(monkeypatch):
         runner_settings_from_env()
         runner_settings_from_env()
 
 
 
 
-def test_runner_registers_bounded_artifact_cleanup_cli():
+def test_runner_registers_bounded_artifact_reconciliation_cli():
     class Resolver:
     class Resolver:
         def __init__(self):
         def __init__(self):
             self.limit = None
             self.limit = None
+            self.grace_seconds = None
 
 
-        def cleanup_expired(self, *, limit):
+        def reconcile(self, *, limit, grace_seconds):
             self.limit = limit
             self.limit = limit
-            return 3
+            self.grace_seconds = grace_seconds
+            return {
+                "pending_finalized": 1,
+                "pending_deleted": 1,
+                "ready_failed": 1,
+                "orphans_deleted": 1,
+            }
 
 
     resolver = Resolver()
     resolver = Resolver()
     app = Flask("runner-cleanup-test")
     app = Flask("runner-cleanup-test")
     register_artifact_cleanup_cli(app, resolver)
     register_artifact_cleanup_cli(app, resolver)
 
 
     result = app.test_cli_runner().invoke(
     result = app.test_cli_runner().invoke(
-        args=["cleanup-rule-artifacts", "--limit", "25"]
+        args=[
+            "reconcile-rule-artifacts",
+            "--limit",
+            "25",
+            "--grace-seconds",
+            "600",
+        ]
     )
     )
 
 
     assert result.exit_code == 0
     assert result.exit_code == 0
-    assert result.output.strip() == "removed 3 expired rule artifacts"
+    assert "pending_finalized=1" in result.output
+    assert "orphans_deleted=1" in result.output
     assert resolver.limit == 25
     assert resolver.limit == 25
+    assert resolver.grace_seconds == 600

+ 65 - 1
tests/runner/test_polars_worker.py

@@ -53,7 +53,7 @@ def _assert_worker_resource_failure(plan, tmp_path, *, lookup_paths=None):
 
 
     with pytest.raises(
     with pytest.raises(
         PolarsWorkerResourceError,
         PolarsWorkerResourceError,
-        match="hard memory limit",
+        match="hard memory limit|bounded execution time",
     ):
     ):
         execute_isolated_polars_plan(
         execute_isolated_polars_plan(
             {
             {
@@ -97,6 +97,70 @@ def test_isolated_polars_worker_fails_deterministically_at_hard_memory_limit():
         )
         )
 
 
 
 
+def test_worker_start_failure_closes_pipe_endpoints_and_maps_safe_error(
+    monkeypatch,
+):
+    from app.runner import polars_worker
+
+    class Endpoint:
+        def __init__(self):
+            self.closed = False
+
+        def close(self):
+            self.closed = True
+
+    class Process:
+        pid = None
+
+        def __init__(self):
+            self.joined = False
+            self.closed = False
+
+        def start(self):
+            raise OSError("sensitive spawn detail")
+
+        def is_alive(self):
+            return False
+
+        def join(self, timeout=None):
+            self.joined = True
+
+        def close(self):
+            self.closed = True
+
+    parent = Endpoint()
+    child = Endpoint()
+    process = Process()
+
+    class Context:
+        def Pipe(self, duplex):
+            assert duplex is False
+            return parent, child
+
+        def Process(self, **_kwargs):
+            return process
+
+    monkeypatch.setattr(
+        polars_worker.multiprocessing,
+        "get_context",
+        lambda _method: Context(),
+    )
+
+    with pytest.raises(
+        polars_worker.PolarsWorkerError,
+        match="failed to start safely",
+    ) as error:
+        polars_worker.run_isolated_memory_probe(
+            allocate_bytes=1,
+            memory_limit_bytes=1024,
+        )
+
+    assert "sensitive" not in str(error.value)
+    assert parent.closed is True
+    assert child.closed is True
+    assert process.closed is True
+
+
 def test_regex_peak_allocation_fails_inside_isolated_worker(tmp_path):
 def test_regex_peak_allocation_fails_inside_isolated_worker(tmp_path):
     schema = _schema(
     schema = _schema(
         "bd:regex:raw",
         "bd:regex:raw",

+ 79 - 9
tests/runner/test_rule_polars.py

@@ -31,11 +31,13 @@ def _plan_store(client):
 
 
 
 
 class Resolver:
 class Resolver:
-    def __init__(self, values):
+    def __init__(self, values, artifact_store):
         self.values = values
         self.values = values
+        self.artifact_store = artifact_store
         self.calls = []
         self.calls = []
         self.events = []
         self.events = []
         self.attest_error = None
         self.attest_error = None
+        self.publish_error = None
         self.registrations = []
         self.registrations = []
 
 
     def resolve(self, *, binding_id, correlation_id, kind):
     def resolve(self, *, binding_id, correlation_id, kind):
@@ -49,16 +51,28 @@ class Resolver:
             raise self.attest_error
             raise self.attest_error
         return {"binding_hash": binding_hash}
         return {"binding_hash": binding_hash}
 
 
-    def register(
+    def publish_path(
         self,
         self,
+        path,
         *,
         *,
         binding_id,
         binding_id,
         correlation_id,
         correlation_id,
-        artifact,
         kind,
         kind,
         binding_hash,
         binding_hash,
+        ttl_seconds,
+        schema_fields,
+        limits,
     ):
     ):
-        self.events.append(("register", binding_id, kind))
+        self.events.append(("publish", binding_id, kind))
+        if self.publish_error is not None:
+            raise self.publish_error
+        artifact = self.artifact_store.write_path(
+            path,
+            correlation_id,
+            ttl_seconds,
+            schema_fields=schema_fields,
+            limits=limits,
+        )
         self.registrations.append(
         self.registrations.append(
             {
             {
                 "binding_id": binding_id,
                 "binding_id": binding_id,
@@ -160,7 +174,8 @@ def test_polars_adapter_reconstructs_assert_and_deduplicate_and_writes_artifact(
                 **source,
                 **source,
                 "binding_hash": compiled["plan"]["input_binding_hash"],
                 "binding_hash": compiled["plan"]["input_binding_hash"],
             }
             }
-        }
+        },
+        store,
     )
     )
     adapter = PolarsRulePlanAdapter(
     adapter = PolarsRulePlanAdapter(
         artifact_store=store,
         artifact_store=store,
@@ -213,6 +228,57 @@ def test_polars_adapter_reconstructs_assert_and_deduplicate_and_writes_artifact(
     ]
     ]
 
 
 
 
+def test_polars_adapter_reports_unknown_catalog_commit_outcome():
+    from app.runner.artifacts import ArtifactCommitUnknown
+    from app.runner.rule_polars import PolarsRulePlanAdapter
+
+    compiled, input_binding = _compiled_plan(
+        [
+            {
+                "id": "trim_name",
+                "op": "normalize_text",
+                "column": "name",
+                "trim": True,
+            }
+        ]
+    )
+    store = _plan_store(FakeMinio())
+    correlation_id = new_governance_uid()
+    source = store.write(
+        pl.DataFrame(
+            {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
+        ),
+        correlation_id,
+        600,
+        schema_fields=compiled["plan"]["input_fields"],
+        limits=compiled["plan"]["resource_limits"],
+    )
+    resolver = Resolver(
+        {
+            input_binding["id"]: {
+                **source,
+                "binding_hash": compiled["plan"]["input_binding_hash"],
+            }
+        },
+        store,
+    )
+    resolver.publish_error = ArtifactCommitUnknown("lost acknowledgement")
+
+    with pytest.raises(NodeExecutionError, match="commit outcome") as error:
+        PolarsRulePlanAdapter(
+            artifact_store=store,
+            artifact_resolver=resolver,
+        ).execute(
+            plan=compiled["plan"],
+            node=_node(compiled),
+            parameters={},
+            write_authorized=True,
+            correlation_id=correlation_id,
+        )
+
+    assert error.value.commit_outcome == "unknown"
+
+
 def test_polars_adapter_fails_closed_for_plan_hash_binding_and_authorization():
 def test_polars_adapter_fails_closed_for_plan_hash_binding_and_authorization():
     from app.runner.rule_polars import PolarsRulePlanAdapter
     from app.runner.rule_polars import PolarsRulePlanAdapter
 
 
@@ -243,7 +309,8 @@ def test_polars_adapter_fails_closed_for_plan_hash_binding_and_authorization():
                 **source,
                 **source,
                 "binding_hash": "0" * 64,
                 "binding_hash": "0" * 64,
             }
             }
-        }
+        },
+        store,
     )
     )
     adapter = PolarsRulePlanAdapter(
     adapter = PolarsRulePlanAdapter(
         artifact_store=store,
         artifact_store=store,
@@ -323,7 +390,8 @@ def test_polars_adapter_attests_current_output_binding_before_reading_input():
                 **source,
                 **source,
                 "binding_hash": compiled["plan"]["input_binding_hash"],
                 "binding_hash": compiled["plan"]["input_binding_hash"],
             }
             }
-        }
+        },
+        store,
     )
     )
     resolver.attest_error = ValueError("binding changed")
     resolver.attest_error = ValueError("binding changed")
     reads_before_execute = list(store.client.get_calls)
     reads_before_execute = list(store.client.get_calls)
@@ -425,7 +493,8 @@ def test_polars_adapter_uses_exact_decimal_and_timestamptz_output_contracts():
                 **source,
                 **source,
                 "binding_hash": compiled["plan"]["input_binding_hash"],
                 "binding_hash": compiled["plan"]["input_binding_hash"],
             }
             }
-        }
+        },
+        store,
     )
     )
 
 
     result = PolarsRulePlanAdapter(
     result = PolarsRulePlanAdapter(
@@ -526,7 +595,8 @@ def test_polars_expression_date_and_timestamp_use_plan_timezone():
                 **source,
                 **source,
                 "binding_hash": compiled["plan"]["input_binding_hash"],
                 "binding_hash": compiled["plan"]["input_binding_hash"],
             }
             }
-        }
+        },
+        store,
     )
     )
 
 
     result = PolarsRulePlanAdapter(
     result = PolarsRulePlanAdapter(

+ 31 - 2
tests/test_data_rule_schema.py

@@ -30,6 +30,12 @@ ARTIFACT_CATALOG_MIGRATION = (
     / "versions"
     / "versions"
     / "20260723_140_rule_run_artifacts.py"
     / "20260723_140_rule_run_artifacts.py"
 )
 )
+ARTIFACT_HANDOFF_MIGRATION = (
+    ROOT
+    / "migrations"
+    / "versions"
+    / "20260723_150_rule_artifact_handoff_state.py"
+)
 
 
 EXPECTED_TABLES = {
 EXPECTED_TABLES = {
     "data_rules",
     "data_rules",
@@ -148,15 +154,38 @@ def test_rule_run_artifact_catalog_is_correlation_scoped_and_forward_preserving(
         "artifact_digest CHAR(64) NOT NULL",
         "artifact_digest CHAR(64) NOT NULL",
         "schema_fields JSONB NOT NULL",
         "schema_fields JSONB NOT NULL",
         "expires_at TIMESTAMPTZ NOT NULL",
         "expires_at TIMESTAMPTZ NOT NULL",
+        "UNIQUE (correlation_id, binding_id, artifact_digest)",
+    ):
+        assert expected in source
+    downgrade = source.split("def downgrade()", 1)[1]
+    assert "DROP TABLE" not in downgrade.upper()
+    assert "pass" in downgrade
+
+
+def test_artifact_handoff_state_migration_upgrades_old_140_forward_only():
+    source = ARTIFACT_HANDOFF_MIGRATION.read_text(encoding="utf-8")
+
+    assert 'revision = "20260723_150"' in source
+    assert 'down_revision = "20260723_140"' in source
+    for expected in (
+        "binding_hash CHAR(64)",
+        "handoff_status VARCHAR(20)",
+        "ready_at TIMESTAMPTZ",
+        "failed_at TIMESTAMPTZ",
+        "updated_at TIMESTAMPTZ",
         "UNIQUE (correlation_id, binding_id, artifact_kind)",
         "UNIQUE (correlation_id, binding_id, artifact_kind)",
+        "'pending','ready','failed'",
+        "SELECT binding_hash",
     ):
     ):
         assert expected in source
         assert expected in source
+    assert "artifact_digest" in source
+    assert "RAISE EXCEPTION" in source
     downgrade = source.split("def downgrade()", 1)[1]
     downgrade = source.split("def downgrade()", 1)[1]
     assert "DROP TABLE" not in downgrade.upper()
     assert "DROP TABLE" not in downgrade.upper()
     assert "raise RuntimeError" in downgrade
     assert "raise RuntimeError" in downgrade
     spec = importlib.util.spec_from_file_location(
     spec = importlib.util.spec_from_file_location(
-        "rule_run_artifact_catalog_migration",
-        ARTIFACT_CATALOG_MIGRATION,
+        "rule_artifact_handoff_state_migration",
+        ARTIFACT_HANDOFF_MIGRATION,
     )
     )
     assert spec is not None and spec.loader is not None
     assert spec is not None and spec.loader is not None
     module = importlib.util.module_from_spec(spec)
     module = importlib.util.module_from_spec(spec)