Преглед на файлове

fix: enforce isolated Polars artifact invariants

马小龙 преди 3 месеца
родител
ревизия
8bffdb6bd1

+ 23 - 15
app/core/data_rules/compilers/polars.py

@@ -102,7 +102,7 @@ _OPERATION_KEYS = {
         "severity",
         "severity",
         "step_id",
         "step_id",
     },
     },
-    "cast": {"op", "column", "to", "on_error"},
+    "cast": {"op", "column", "to", "on_error", "target_field"},
     "deduplicate": {"op", "keys", "order_by", "keep"},
     "deduplicate": {"op", "keys", "order_by", "keep"},
     "derive": {"op", "target", "expression_ast"},
     "derive": {"op", "target", "expression_ast"},
     "fill_null": {"op", "column", "value"},
     "fill_null": {"op", "column", "value"},
@@ -312,16 +312,6 @@ def _semantic_type(field: dict[str, Any]) -> tuple[Any, ...]:
     return (field_type,)
     return (field_type,)
 
 
 
 
-def _field_for_type(
-    field_type: str,
-    *,
-    output_field: dict[str, Any] | None = None,
-) -> dict[str, Any]:
-    if output_field is not None and output_field["type"] == field_type:
-        return copy.deepcopy(output_field)
-    return {"name": "", "type": field_type, "nullable": True}
-
-
 def _timezone(value: Any) -> str:
 def _timezone(value: Any) -> str:
     timezone = str(value or "")
     timezone = str(value or "")
     if not timezone or len(timezone) > 100:
     if not timezone or len(timezone) > 100:
@@ -431,6 +421,13 @@ def _operation(value: Any) -> dict[str, Any]:
         result["to"] not in _CAST_TYPES or result["on_error"] != "fail"
         result["to"] not in _CAST_TYPES or result["on_error"] != "fail"
     ):
     ):
         raise ValueError("cast operation is invalid")
         raise ValueError("cast operation is invalid")
+    if operation == "cast":
+        canonical_schema_hash([result["target_field"]])
+        target_field = _field_contracts([result["target_field"]])
+        target = target_field.get(result["column"])
+        if target is None or target["type"] != result["to"]:
+            raise ValueError("cast target_field does not match its column/type")
+        result["target_field"] = target
     if operation == "deduplicate":
     if operation == "deduplicate":
         _string_list(result["keys"], "deduplicate keys")
         _string_list(result["keys"], "deduplicate keys")
         _string_list(result["order_by"], "deduplicate order_by")
         _string_list(result["order_by"], "deduplicate order_by")
@@ -589,11 +586,9 @@ def validate_bound_polars_plan(value: Any) -> dict[str, Any]:
             column = operation["column"]
             column = operation["column"]
             if column not in current_fields:
             if column not in current_fields:
                 raise ValueError("cast references an unknown field")
                 raise ValueError("cast references an unknown field")
-            target = output_fields.get(column)
-            current_fields[column] = _field_for_type(
-                operation["to"], output_field=target
+            current_fields[column] = copy.deepcopy(
+                operation["target_field"]
             )
             )
-            current_fields[column]["name"] = column
             current_types[column] = operation["to"]
             current_types[column] = operation["to"]
         elif op == "deduplicate":
         elif op == "deduplicate":
             if not set(operation["keys"] + operation["order_by"]) <= set(
             if not set(operation["keys"] + operation["order_by"]) <= set(
@@ -721,6 +716,10 @@ class PolarsRuleCompiler(RuleCompiler):
         if "polars" not in supported:
         if "polars" not in supported:
             raise ValueError("rule expressions are unsupported by polars")
             raise ValueError("rule expressions are unsupported by polars")
         output_types = _fields(target_schema)
         output_types = _fields(target_schema)
+        output_field_contracts = {
+            field["name"]: copy.deepcopy(field)
+            for field in target_schema["fields"]
+        }
         current_fields = list(field_types)
         current_fields = list(field_types)
         operations = []
         operations = []
 
 
@@ -851,12 +850,21 @@ class PolarsRuleCompiler(RuleCompiler):
                 target_type = str(step.get("to") or "").lower()
                 target_type = str(step.get("to") or "").lower()
                 if target_type not in _CAST_TYPES:
                 if target_type not in _CAST_TYPES:
                     raise ValueError("unsupported Polars cast target")
                     raise ValueError("unsupported Polars cast target")
+                target_field = output_field_contracts.get(column)
+                if (
+                    target_field is None
+                    or target_field["type"] != target_type
+                ):
+                    raise ValueError(
+                        "cast target does not match the output schema"
+                    )
                 operations.append(
                 operations.append(
                     {
                     {
                         "op": op,
                         "op": op,
                         "column": column,
                         "column": column,
                         "to": target_type,
                         "to": target_type,
                         "on_error": "fail",
                         "on_error": "fail",
+                        "target_field": target_field,
                     }
                     }
                 )
                 )
                 field_types[column] = target_type
                 field_types[column] = target_type

+ 303 - 86
app/runner/artifacts.py

@@ -3,18 +3,17 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import hashlib
 import hashlib
-import io
 import json
 import json
 import os
 import os
 import re
 import re
 import tempfile
 import tempfile
-from base64 import urlsafe_b64decode, urlsafe_b64encode
 from collections.abc import Mapping
 from collections.abc import Mapping
-from contextlib import suppress
+from contextlib import contextmanager, suppress
 from datetime import UTC, datetime, timedelta
 from datetime import UTC, datetime, timedelta
 from typing import Any
 from typing import Any
 
 
 import polars as pl
 import polars as pl
+import pyarrow.parquet as pq
 from sqlalchemy import text
 from sqlalchemy import text
 
 
 from app.core.common.identifiers import (
 from app.core.common.identifiers import (
@@ -27,6 +26,22 @@ PARQUET_CONTENT_TYPE = "application/x-parquet"
 _DIGEST = re.compile(r"^[0-9a-f]{64}$")
 _DIGEST = re.compile(r"^[0-9a-f]{64}$")
 
 
 
 
+def _parquet_footer_bounds(path: str) -> tuple[int, int]:
+    try:
+        metadata = pq.ParquetFile(path).metadata
+    except Exception as exc:
+        raise ValueError("artifact Parquet footer is invalid") from exc
+    if metadata is None or metadata.num_row_groups < 1:
+        raise ValueError("artifact Parquet footer is incomplete")
+    uncompressed = sum(
+        metadata.row_group(index).total_byte_size
+        for index in range(metadata.num_row_groups)
+    )
+    if metadata.num_rows < 0 or uncompressed < 0:
+        raise ValueError("artifact Parquet footer is invalid")
+    return metadata.num_rows, uncompressed
+
+
 def _now_utc(clock) -> datetime:
 def _now_utc(clock) -> datetime:
     value = clock()
     value = clock()
     if not isinstance(value, datetime):
     if not isinstance(value, datetime):
@@ -111,28 +126,9 @@ def _normalized_schema_fields(value: Any) -> list[dict[str, Any]]:
     )
     )
 
 
 
 
-def _schema_contract(value: Any) -> tuple[list[dict[str, Any]], str, str]:
+def _schema_contract(value: Any) -> tuple[list[dict[str, Any]], str]:
     fields = _normalized_schema_fields(value)
     fields = _normalized_schema_fields(value)
-    encoded = json.dumps(
-        fields,
-        sort_keys=True,
-        separators=(",", ":"),
-        ensure_ascii=False,
-    ).encode("utf-8")
-    return (
-        fields,
-        canonical_schema_hash(fields),
-        urlsafe_b64encode(encoded).decode("ascii"),
-    )
-
-
-def _decode_schema_contract(value: str) -> list[dict[str, Any]]:
-    try:
-        decoded = urlsafe_b64decode(value.encode("ascii"))
-        fields = json.loads(decoded.decode("utf-8"))
-    except Exception as exc:
-        raise ValueError("artifact schema contract metadata is invalid") from exc
-    return _normalized_schema_fields(fields)
+    return fields, canonical_schema_hash(fields)
 
 
 
 
 def _validate_frame_schema(
 def _validate_frame_schema(
@@ -200,7 +196,6 @@ def _metadata(value: Any) -> dict[str, str]:
             "schema-sha256",
             "schema-sha256",
             "expires-at",
             "expires-at",
             "artifact-bytes",
             "artifact-bytes",
-            "schema-contract",
         }:
         }:
             normalized[name] = str(item)
             normalized[name] = str(item)
     required = {
     required = {
@@ -209,10 +204,11 @@ def _metadata(value: Any) -> dict[str, str]:
         "schema-sha256",
         "schema-sha256",
         "expires-at",
         "expires-at",
         "artifact-bytes",
         "artifact-bytes",
-        "schema-contract",
     }
     }
     if set(normalized) != required:
     if set(normalized) != required:
         raise ValueError("artifact content metadata is incomplete")
         raise ValueError("artifact content metadata is incomplete")
+    if sum(len(key) + len(item) for key, item in normalized.items()) > 2_048:
+        raise ValueError("artifact content metadata exceeds the safe limit")
     return normalized
     return normalized
 
 
 
 
@@ -357,7 +353,7 @@ class ArtifactStore:
             raise ValueError("artifact row count exceeds the configured limit")
             raise ValueError("artifact row count exceeds the configured limit")
         if collected.estimated_size() > effective["memory_limit_bytes"]:
         if collected.estimated_size() > effective["memory_limit_bytes"]:
             raise ValueError("artifact frame exceeds the configured memory limit")
             raise ValueError("artifact frame exceeds the configured memory limit")
-        fields, schema_digest, schema_encoded = _schema_contract(
+        fields, schema_digest = _schema_contract(
             schema_fields or _inferred_schema_fields(collected)
             schema_fields or _inferred_schema_fields(collected)
         )
         )
         _validate_frame_schema(collected, fields)
         _validate_frame_schema(collected, fields)
@@ -406,7 +402,6 @@ class ArtifactStore:
                         "schema-sha256": schema_digest,
                         "schema-sha256": schema_digest,
                         "expires-at": expires_at,
                         "expires-at": expires_at,
                         "artifact-bytes": str(size),
                         "artifact-bytes": str(size),
-                        "schema-contract": schema_encoded,
                     },
                     },
                 )
                 )
             uploaded = True
             uploaded = True
@@ -416,12 +411,13 @@ class ArtifactStore:
                 limits=effective,
                 limits=effective,
             )
             )
             artifact_ref = f"minio://{self.bucket}/{key}"
             artifact_ref = f"minio://{self.bucket}/{key}"
-            self.read(
+            with self.stage(
                 artifact_ref,
                 artifact_ref,
                 digest_hex,
                 digest_hex,
                 expected_schema_fields=fields,
                 expected_schema_fields=fields,
                 limits=effective,
                 limits=effective,
-            )
+            ):
+                pass
         except Exception:
         except Exception:
             if uploaded:
             if uploaded:
                 with suppress(Exception):
                 with suppress(Exception):
@@ -440,20 +436,100 @@ class ArtifactStore:
             "expires_at": expires_at,
             "expires_at": expires_at,
         }
         }
 
 
+    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]:
+        """Upload a worker-produced Parquet file without collecting it."""
+
+        effective = self._limits(limits)
+        correlation = _uid(correlation_id, "correlation_id")
+        if (
+            isinstance(ttl_seconds, bool)
+            or not isinstance(ttl_seconds, int)
+            or ttl_seconds < 1
+            or ttl_seconds > self.max_ttl_seconds
+        ):
+            raise ValueError("artifact TTL is outside the configured limit")
+        if not isinstance(path, str) or not os.path.isfile(path):
+            raise ValueError("worker artifact path is invalid")
+        size = os.path.getsize(path)
+        if size < 1 or size > effective["max_artifact_bytes"]:
+            raise ValueError("artifact size exceeds the configured limit")
+        rows, uncompressed = _parquet_footer_bounds(path)
+        if rows > effective["max_rows"]:
+            raise ValueError("artifact row count exceeds the configured limit")
+        if uncompressed > effective["memory_limit_bytes"]:
+            raise ValueError(
+                "Parquet footer uncompressed size exceeds the memory limit"
+            )
+        fields, schema_digest = _schema_contract(schema_fields)
+        _validate_frame_schema(pl.scan_parquet(path), fields)
+        digest = hashlib.sha256()
+        with open(path, "rb") as handle:
+            while chunk := handle.read(1024 * 1024):
+                digest.update(chunk)
+        digest_hex = digest.hexdigest()
+        expires_at = _timestamp(
+            _now_utc(self.clock) + timedelta(seconds=ttl_seconds)
+        )
+        artifact_id = new_governance_uid()
+        key = f"rules/{correlation}/{artifact_id}.parquet"
+        uploaded = False
+        try:
+            with open(path, "rb") as handle:
+                self.client.put_object(
+                    self.bucket,
+                    key,
+                    handle,
+                    size,
+                    content_type=PARQUET_CONTENT_TYPE,
+                    metadata={
+                        "sha256": digest_hex,
+                        "row-count": str(rows),
+                        "schema-sha256": schema_digest,
+                        "expires-at": expires_at,
+                        "artifact-bytes": str(size),
+                    },
+                )
+            uploaded = True
+            artifact_ref = f"minio://{self.bucket}/{key}"
+            with self.stage(
+                artifact_ref,
+                digest_hex,
+                expected_schema_fields=fields,
+                limits=effective,
+            ):
+                pass
+        except Exception:
+            if uploaded:
+                with suppress(Exception):
+                    self.client.remove_object(self.bucket, key)
+            raise
+        return {
+            "artifact_ref": artifact_ref,
+            "digest": digest_hex,
+            "row_count": rows,
+            "schema_hash": schema_digest,
+            "schema_fields": fields,
+            "expires_at": expires_at,
+        }
+
     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."""
 
 
         key = self._parse_ref(ref)
         key = self._parse_ref(ref)
         _stat, metadata = self._validated_stat(key)
         _stat, metadata = self._validated_stat(key)
-        fields = _decode_schema_contract(metadata["schema-contract"])
-        if canonical_schema_hash(fields) != metadata["schema-sha256"]:
-            raise ValueError("artifact schema contract does not match")
         return {
         return {
             "artifact_ref": ref,
             "artifact_ref": ref,
             "digest": metadata["sha256"],
             "digest": metadata["sha256"],
             "row_count": int(metadata["row-count"]),
             "row_count": int(metadata["row-count"]),
             "schema_hash": metadata["schema-sha256"],
             "schema_hash": metadata["schema-sha256"],
-            "schema_fields": fields,
             "expires_at": metadata["expires-at"],
             "expires_at": metadata["expires-at"],
         }
         }
 
 
@@ -465,6 +541,37 @@ class ArtifactStore:
         expected_schema_fields: list[dict[str, Any]] | None = None,
         expected_schema_fields: list[dict[str, Any]] | None = None,
         limits: dict[str, int] | None = None,
         limits: dict[str, int] | None = None,
     ) -> pl.LazyFrame:
     ) -> pl.LazyFrame:
+        with self.stage(
+            ref,
+            expected_digest,
+            expected_schema_fields=expected_schema_fields,
+            limits=limits,
+        ) as path:
+            effective = self._limits(limits)
+            try:
+                frame = pl.read_parquet(
+                    path,
+                    n_rows=effective["max_rows"] + 1,
+                    memory_map=False,
+                )
+            except Exception as exc:
+                raise ValueError("artifact is not valid Parquet") from exc
+        if frame.estimated_size() > effective["memory_limit_bytes"]:
+            raise ValueError("artifact frame exceeds the configured memory limit")
+        _validate_frame_schema(
+            frame, _normalized_schema_fields(expected_schema_fields)
+        )
+        return frame.lazy()
+
+    @contextmanager
+    def stage(
+        self,
+        ref: str,
+        expected_digest: str,
+        *,
+        expected_schema_fields: list[dict[str, Any]] | None = None,
+        limits: dict[str, int] | None = None,
+    ):
         effective = self._limits(limits)
         effective = self._limits(limits)
         if _DIGEST.fullmatch(str(expected_digest or "")) is None:
         if _DIGEST.fullmatch(str(expected_digest or "")) is None:
             raise ValueError("expected artifact digest is invalid")
             raise ValueError("expected artifact digest is invalid")
@@ -474,56 +581,56 @@ class ArtifactStore:
             expected_digest=expected_digest,
             expected_digest=expected_digest,
             limits=effective,
             limits=effective,
         )
         )
-        fields = _decode_schema_contract(metadata["schema-contract"])
+        if expected_schema_fields is None:
+            raise ValueError("expected artifact schema fields are required")
+        fields = _normalized_schema_fields(expected_schema_fields)
         if canonical_schema_hash(fields) != metadata["schema-sha256"]:
         if canonical_schema_hash(fields) != metadata["schema-sha256"]:
-            raise ValueError("artifact schema contract does not match")
-        if expected_schema_fields is not None:
-            expected = _normalized_schema_fields(expected_schema_fields)
-            if expected != fields:
-                raise ValueError("artifact schema contract is not expected")
+            raise ValueError("artifact schema contract is not expected")
         response = self.client.get_object(self.bucket, key)
         response = self.client.get_object(self.bucket, key)
         digest = hashlib.sha256()
         digest = hashlib.sha256()
-        payload = io.BytesIO()
+        path = None
         size = 0
         size = 0
         try:
         try:
-            while chunk := response.read(1024 * 1024):
-                size += len(chunk)
-                if size > effective["max_artifact_bytes"]:
-                    raise ValueError(
-                        "artifact size exceeds the configured limit"
-                    )
-                if size > effective["memory_limit_bytes"]:
-                    raise ValueError(
-                        "artifact download exceeds the memory limit"
-                    )
-                digest.update(chunk)
-                payload.write(chunk)
+            try:
+                with tempfile.NamedTemporaryFile(
+                    prefix="dataops-rule-stage-",
+                    suffix=".parquet",
+                    delete=False,
+                ) as handle:
+                    path = handle.name
+                    while chunk := response.read(1024 * 1024):
+                        size += len(chunk)
+                        if size > effective["max_artifact_bytes"]:
+                            raise ValueError(
+                                "artifact size exceeds the configured limit"
+                            )
+                        digest.update(chunk)
+                        handle.write(chunk)
+            finally:
+                response.close()
+                release = getattr(response, "release_conn", None)
+                if callable(release):
+                    release()
+            if digest.hexdigest() != expected_digest:
+                raise ValueError("artifact digest does not match content")
+            footer_rows, uncompressed = _parquet_footer_bounds(path)
+            if footer_rows != int(metadata["row-count"]):
+                raise ValueError("artifact row count does not match metadata")
+            if footer_rows > effective["max_rows"]:
+                raise ValueError(
+                    "artifact row count exceeds the configured limit"
+                )
+            if uncompressed > effective["memory_limit_bytes"]:
+                raise ValueError(
+                    "Parquet footer uncompressed size exceeds the memory limit"
+                )
+            lazy = pl.scan_parquet(path)
+            _validate_frame_schema(lazy, fields)
+            yield path
         finally:
         finally:
-            response.close()
-            release = getattr(response, "release_conn", None)
-            if callable(release):
-                release()
-        if digest.hexdigest() != expected_digest:
-            raise ValueError("artifact digest does not match content")
-        payload.seek(0)
-        try:
-            frame = pl.read_parquet(
-                payload,
-                n_rows=effective["max_rows"] + 1,
-                memory_map=False,
-            )
-        except Exception as exc:
-            raise ValueError("artifact is not valid Parquet") from exc
-        if frame.height != int(metadata["row-count"]):
-            raise ValueError("artifact row count does not match metadata")
-        if frame.height > effective["max_rows"]:
-            raise ValueError("artifact row count exceeds the configured limit")
-        if frame.estimated_size() > effective["memory_limit_bytes"]:
-            raise ValueError("artifact frame exceeds the configured memory limit")
-        if size + frame.estimated_size() > effective["memory_limit_bytes"]:
-            raise ValueError("artifact decompression exceeds the memory limit")
-        _validate_frame_schema(frame, fields)
-        return frame.lazy()
+            if path is not None:
+                with suppress(FileNotFoundError):
+                    os.unlink(path)
 
 
     def cleanup_expired(self, correlation_id: str) -> int:
     def cleanup_expired(self, correlation_id: str) -> int:
         correlation = _uid(correlation_id, "correlation_id")
         correlation = _uid(correlation_id, "correlation_id")
@@ -565,9 +672,17 @@ class PostgresArtifactResolver:
         self.engine = engine
         self.engine = engine
         self.artifact_store = artifact_store
         self.artifact_store = artifact_store
 
 
-    def resolve(self, *, binding_id: str, correlation_id: str) -> dict[str, Any]:
+    def resolve(
+        self,
+        *,
+        binding_id: str,
+        correlation_id: str,
+        kind: str,
+    ) -> dict[str, Any]:
         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"}:
+            raise ValueError("artifact kind is invalid")
         statement = text(
         statement = text(
             """
             """
             SELECT
             SELECT
@@ -583,6 +698,7 @@ class PostgresArtifactResolver:
               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.expires_at > CURRENT_TIMESTAMP
               AND a.expires_at > CURRENT_TIMESTAMP
               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')
@@ -596,6 +712,7 @@ class PostgresArtifactResolver:
                 {
                 {
                     "binding_id": binding,
                     "binding_id": binding,
                     "correlation_id": correlation,
                     "correlation_id": correlation,
+                    "artifact_kind": kind,
                 },
                 },
             ).mappings().one_or_none()
             ).mappings().one_or_none()
         if row is None:
         if row is None:
@@ -614,12 +731,11 @@ class PostgresArtifactResolver:
             described["digest"] != str(row["artifact_digest"])
             described["digest"] != str(row["artifact_digest"])
             or described["row_count"] != int(row["row_count"])
             or described["row_count"] != int(row["row_count"])
             or described["schema_hash"] != str(row["schema_hash"])
             or described["schema_hash"] != str(row["schema_hash"])
-            or described["schema_fields"]
-            != _normalized_schema_fields(row_fields)
         ):
         ):
             raise ValueError("catalog artifact metadata does not match storage")
             raise ValueError("catalog artifact metadata does not match storage")
         return {
         return {
             **described,
             **described,
+            "schema_fields": _normalized_schema_fields(row_fields),
             "binding_hash": str(row["binding_hash"]),
             "binding_hash": str(row["binding_hash"]),
         }
         }
 
 
@@ -667,7 +783,7 @@ class PostgresArtifactResolver:
         artifact: dict[str, Any],
         artifact: dict[str, Any],
         kind: str,
         kind: str,
         binding_hash: str,
         binding_hash: str,
-    ) -> None:
+    ) -> dict[str, Any]:
         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"}:
@@ -691,13 +807,16 @@ class PostgresArtifactResolver:
             "digest",
             "digest",
             "row_count",
             "row_count",
             "schema_hash",
             "schema_hash",
-            "schema_fields",
             "expires_at",
             "expires_at",
         ):
         ):
             if described[key] != artifact.get(key):
             if described[key] != artifact.get(key):
                 raise ValueError("artifact metadata does not match storage")
                 raise ValueError("artifact metadata does not match storage")
+        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
         with self.engine.begin() as connection:
         with self.engine.begin() as connection:
-            connection.execute(
+            inserted = connection.execute(
                 text(
                 text(
                     """
                     """
                     INSERT INTO public.rule_run_artifacts (
                     INSERT INTO public.rule_run_artifacts (
@@ -712,8 +831,10 @@ class PostgresArtifactResolver:
                         CAST(:expires_at AS timestamptz)
                         CAST(:expires_at AS timestamptz)
                     )
                     )
                     ON CONFLICT (
                     ON CONFLICT (
-                        correlation_id, binding_id, artifact_digest
+                        correlation_id, binding_id, artifact_kind
                     ) DO NOTHING
                     ) DO NOTHING
+                    RETURNING artifact_ref, artifact_digest, row_count,
+                              schema_hash, schema_fields, expires_at
                     """
                     """
                 ),
                 ),
                 {
                 {
@@ -725,11 +846,107 @@ class PostgresArtifactResolver:
                     "row_count": described["row_count"],
                     "row_count": described["row_count"],
                     "schema_hash": described["schema_hash"],
                     "schema_hash": described["schema_hash"],
                     "schema_fields": json.dumps(
                     "schema_fields": json.dumps(
-                        described["schema_fields"],
+                        fields,
                         sort_keys=True,
                         sort_keys=True,
                         separators=(",", ":"),
                         separators=(",", ":"),
                     ),
                     ),
                     "artifact_kind": kind,
                     "artifact_kind": kind,
                     "expires_at": described["expires_at"],
                     "expires_at": described["expires_at"],
                 },
                 },
+            ).mappings().one_or_none()
+            if inserted is None:
+                inserted = connection.execute(
+                    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)
+                          AND binding_id = CAST(:binding_id AS uuid)
+                          AND artifact_kind = :artifact_kind
+                        FOR UPDATE
+                        """
+                    ),
+                    {
+                        "correlation_id": correlation,
+                        "binding_id": binding,
+                        "artifact_kind": kind,
+                    },
+                ).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)
+            stored = self.artifact_store.describe(
+                registered["artifact_ref"]
             )
             )
+            for key in (
+                "digest",
+                "row_count",
+                "schema_hash",
+                "expires_at",
+            ):
+                if stored[key] != registered[key]:
+                    raise ValueError(
+                        "immutable artifact catalog does not match storage"
+                    )
+        return registered
+
+    def cleanup_expired(self, *, limit: int = 100) -> int:
+        if (
+            isinstance(limit, bool)
+            or not isinstance(limit, int)
+            or limit < 1
+            or limit > 1_000
+        ):
+            raise ValueError("artifact cleanup limit is invalid")
+        removed = 0
+        with self.engine.begin() as connection:
+            rows = connection.execute(
+                text(
+                    """
+                    SELECT id::text AS id, artifact_ref
+                    FROM public.rule_run_artifacts
+                    WHERE expires_at <= CURRENT_TIMESTAMP
+                    ORDER BY expires_at, id
+                    LIMIT :limit
+                    FOR UPDATE SKIP LOCKED
+                    """
+                ),
+                {"limit": limit},
+            ).mappings().all()
+            for row in rows:
+                self.artifact_store.delete(str(row["artifact_ref"]))
+                connection.execute(
+                    text(
+                        """
+                        DELETE FROM public.rule_run_artifacts
+                        WHERE id = CAST(:id AS uuid)
+                        """
+                    ),
+                    {"id": str(row["id"])},
+                )
+                removed += 1
+        return removed

+ 16 - 3
app/runner/bootstrap.py

@@ -5,6 +5,7 @@ from __future__ import annotations
 import os
 import os
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 
 
+import click
 from minio import Minio
 from minio import Minio
 
 
 from app.core.data_source.runtime import (
 from app.core.data_source.runtime import (
@@ -77,6 +78,16 @@ class RunnerSettings:
     max_query_rows: int = 1000
     max_query_rows: int = 1000
 
 
 
 
+def register_artifact_cleanup_cli(app, artifact_resolver):
+    """Register the bounded production maintenance entrypoint."""
+
+    @app.cli.command("cleanup-rule-artifacts")
+    @click.option("--limit", type=click.IntRange(1, 1_000), default=100)
+    def cleanup_rule_artifacts(limit):
+        removed = artifact_resolver.cleanup_expired(limit=limit)
+        click.echo(f"removed {removed} expired rule artifacts")
+
+
 def runner_settings_from_env():
 def runner_settings_from_env():
     task_token_secret = _required("RUNNER_TASK_TOKEN_SECRET")
     task_token_secret = _required("RUNNER_TASK_TOKEN_SECRET")
     worker_count = _integer("RUNNER_WORKERS", 2, 1, 8)
     worker_count = _integer("RUNNER_WORKERS", 2, 1, 8)
@@ -182,11 +193,12 @@ def build_runner_application(settings=None):
         memory_limit_bytes=settings.artifact_memory_limit_bytes,
         memory_limit_bytes=settings.artifact_memory_limit_bytes,
         max_ttl_seconds=settings.artifact_ttl_seconds,
         max_ttl_seconds=settings.artifact_ttl_seconds,
     )
     )
+    artifact_resolver = PostgresArtifactResolver(
+        runtime.platform_engine, artifact_store
+    )
     polars_rule_adapter = PolarsRulePlanAdapter(
     polars_rule_adapter = PolarsRulePlanAdapter(
         artifact_store=artifact_store,
         artifact_store=artifact_store,
-        artifact_resolver=PostgresArtifactResolver(
-            runtime.platform_engine, artifact_store
-        ),
+        artifact_resolver=artifact_resolver,
         masking_policies={
         masking_policies={
             "customer_mobile_last4": "preserve_last_4",
             "customer_mobile_last4": "preserve_last_4",
             "redact": "redact",
             "redact": "redact",
@@ -218,6 +230,7 @@ def build_runner_application(settings=None):
         ledger=PostgresTaskLedger(runtime.platform_engine),
         ledger=PostgresTaskLedger(runtime.platform_engine),
         registry=registry,
         registry=registry,
     )
     )
+    register_artifact_cleanup_cli(application, artifact_resolver)
     application.extensions["dataops_runner_runtime"] = runtime
     application.extensions["dataops_runner_runtime"] = runtime
     application.extensions["dataops_runner_settings"] = settings
     application.extensions["dataops_runner_settings"] = settings
     return application
     return application

+ 557 - 0
app/runner/polars_worker.py

@@ -0,0 +1,557 @@
+"""OS-isolated execution boundary for allocation-heavy Polars work."""
+
+from __future__ import annotations
+
+import multiprocessing
+import os
+import time
+from contextlib import suppress
+from decimal import Decimal
+from typing import Any
+
+try:
+    import resource
+except ImportError:  # pragma: no cover - fail-closed portability guard
+    resource = None
+
+import polars as pl
+import psutil
+
+from app.core.data_rules.compilers.polars import validate_bound_polars_plan
+
+
+class PolarsWorkerError(RuntimeError):
+    """A bounded worker failed without exposing internal data."""
+
+
+class PolarsWorkerResourceError(PolarsWorkerError):
+    """The worker crossed a hard process resource boundary."""
+
+
+def _set_process_memory_limit(
+    *,
+    baseline_vms: int,
+    memory_limit_bytes: int,
+) -> None:
+    if resource is None or not hasattr(resource, "RLIMIT_AS"):
+        raise OSError("OS address-space limits are unavailable")
+    hard_limit = baseline_vms + memory_limit_bytes
+    resource.setrlimit(resource.RLIMIT_AS, (hard_limit, hard_limit))
+
+
+def _memory_probe_entry(
+    connection,
+    allocate_bytes: int,
+    memory_limit_bytes: int,
+) -> None:
+    process = psutil.Process()
+    memory = process.memory_info()
+    connection.send(
+        {
+            "kind": "ready",
+            "pid": os.getpid(),
+            "baseline_rss": memory.rss,
+        }
+    )
+    try:
+        _set_process_memory_limit(
+            baseline_vms=memory.vms,
+            memory_limit_bytes=memory_limit_bytes,
+        )
+        allocated = bytearray(allocate_bytes)
+        for index in range(0, len(allocated), 4096):
+            allocated[index] = 1
+        connection.send(
+            {
+                "kind": "result",
+                "worker_pid": os.getpid(),
+                "allocated_bytes": len(allocated),
+            }
+        )
+    except (MemoryError, OSError):
+        with suppress(Exception):
+            connection.send({"kind": "resource_error"})
+    except BaseException:
+        with suppress(Exception):
+            connection.send({"kind": "worker_error"})
+    finally:
+        connection.close()
+
+
+class _ExpressionCompiler:
+    def __init__(self, timezone: str):
+        self.timezone = timezone
+
+    def compile(self, ast: dict[str, Any]) -> pl.Expr:
+        kind = ast["kind"]
+        if kind == "identifier":
+            return pl.col(ast["name"])
+        if kind == "literal":
+            value = ast["value"]
+            if ast["type"] == "decimal":
+                value = Decimal(value)
+            return pl.lit(value)
+        if kind == "unary":
+            operand = self.compile(ast["operand"])
+            return ~operand if ast["operator"] == "!" else -operand
+        if kind == "binary":
+            left = self.compile(ast["left"])
+            right = self.compile(ast["right"])
+            return {
+                "||": lambda: left | right,
+                "&&": lambda: left & right,
+                "==": lambda: left == right,
+                "!=": lambda: left != right,
+                "<": lambda: left < right,
+                "<=": lambda: left <= right,
+                ">": lambda: left > right,
+                ">=": lambda: left >= right,
+                "+": lambda: left + right,
+                "-": lambda: left - right,
+                "*": lambda: left * right,
+                "/": lambda: left / right,
+                "%": lambda: left % right,
+            }[ast["operator"]]()
+        function = ast["function"]
+        arguments = [self.compile(item) for item in ast["arguments"]]
+        if function == "matches":
+            return arguments[0].str.contains(
+                ast["arguments"][1]["value"], strict=True
+            )
+        if function == "lower":
+            return arguments[0].str.to_lowercase()
+        if function == "upper":
+            return arguments[0].str.to_uppercase()
+        if function == "trim":
+            return arguments[0].str.strip_chars()
+        if function == "length":
+            return arguments[0].str.len_chars()
+        if function == "coalesce":
+            return pl.coalesce(arguments)
+        if function == "date":
+            return (
+                arguments[0]
+                .cast(pl.String)
+                .str.to_datetime(time_zone=self.timezone, strict=True)
+                .dt.date()
+            )
+        if function == "timestamp":
+            return (
+                arguments[0]
+                .cast(pl.String)
+                .str.to_datetime(time_zone=self.timezone, strict=True)
+                .dt.replace_time_zone(None)
+            )
+        if function == "abs":
+            return arguments[0].abs()
+        raise ValueError("published Polars expression is unsupported")
+
+
+def _cast_type(field: dict[str, Any]) -> pl.DataType:
+    field_type = field["type"]
+    if field_type == "boolean":
+        return pl.Boolean
+    if field_type == "date":
+        return pl.Date
+    if field_type == "decimal":
+        return pl.Decimal(
+            precision=field["precision"],
+            scale=field["scale"],
+        )
+    if field_type == "double":
+        return pl.Float64
+    if field_type == "float":
+        return pl.Float32
+    if field_type == "integer":
+        return pl.Int64
+    if field_type == "string":
+        return pl.String
+    if field_type == "timestamp":
+        return pl.Datetime
+    if field_type == "timestamptz":
+        return pl.Datetime(time_zone=field["timezone"])
+    raise ValueError("published Polars cast type is unsupported")
+
+
+def _count(frame: pl.LazyFrame) -> int:
+    return int(frame.select(pl.len()).collect(engine="streaming").item())
+
+
+def _validate_lazy_schema(
+    frame: pl.LazyFrame,
+    fields: list[dict[str, Any]],
+) -> None:
+    schema = frame.collect_schema()
+    if set(schema.names()) != {field["name"] for field in fields}:
+        raise ValueError("artifact schema fields do not match")
+    for field in fields:
+        if schema[field["name"]] != _cast_type(field):
+            raise ValueError("artifact schema type does not match")
+    required = [
+        field["name"] for field in fields if not field["nullable"]
+    ]
+    if required:
+        counts = frame.select(
+            [pl.col(name).null_count().alias(name) for name in required]
+        ).collect(engine="streaming")
+        if any(counts[name].item() for name in required):
+            raise ValueError("artifact nullable contract does not match")
+
+
+def _execute_polars_job(job: dict[str, Any]) -> dict[str, Any]:
+    plan = validate_bound_polars_plan(job["plan"])
+    frame = pl.scan_parquet(job["input_path"])
+    _validate_lazy_schema(frame, plan["input_fields"])
+    rows_in = _count(frame)
+    expressions = _ExpressionCompiler(plan["timezone"])
+    violations = []
+    metrics = {
+        "rows_rejected": 0,
+        "rows_filtered": 0,
+        "rows_deduplicated": 0,
+        "rows_join_dropped": 0,
+        "rows_aggregated": 0,
+    }
+    for operation in plan["operations"]:
+        op = operation["op"]
+        before = _count(frame)
+        if op == "normalize_text":
+            expression = pl.col(operation["column"])
+            if operation["trim"]:
+                expression = expression.str.strip_chars()
+            if operation["lowercase"]:
+                expression = expression.str.to_lowercase()
+            if operation["uppercase"]:
+                expression = expression.str.to_uppercase()
+            frame = frame.with_columns(
+                expression.alias(operation["column"])
+            )
+        elif op == "regex_replace":
+            frame = frame.with_columns(
+                pl.col(operation["column"])
+                .str.replace_all(
+                    operation["pattern"], operation["replacement"]
+                )
+                .alias(operation["column"])
+            )
+        elif op == "fill_null":
+            frame = frame.with_columns(
+                pl.col(operation["column"])
+                .fill_null(operation["value"])
+                .alias(operation["column"])
+            )
+        elif op == "filter":
+            frame = frame.filter(
+                expressions.compile(operation["expression_ast"]).fill_null(
+                    False
+                )
+            )
+            metrics["rows_filtered"] += before - _count(frame)
+        elif op == "assert":
+            predicate = expressions.compile(
+                operation["expression_ast"]
+            ).fill_null(False)
+            invalid = int(
+                frame.select((~predicate).sum().alias("count"))
+                .collect(engine="streaming")
+                .item()
+                or 0
+            )
+            violations.append(
+                {"step_id": operation["step_id"], "count": invalid}
+            )
+            metrics["rows_rejected"] += invalid
+            frame = frame.filter(predicate)
+        elif op == "derive":
+            frame = frame.with_columns(
+                expressions.compile(operation["expression_ast"]).alias(
+                    operation["target"]
+                )
+            )
+        elif op == "map_values":
+            column = pl.col(operation["column"])
+            frame = frame.with_columns(
+                column.replace_strict(
+                    operation["mapping"], default=column
+                ).alias(operation["column"])
+            )
+        elif op == "cast":
+            target = operation["target_field"]
+            expression = pl.col(operation["column"])
+            source_type = frame.collect_schema()[operation["column"]]
+            if source_type == pl.String and target["type"] == "timestamp":
+                expression = (
+                    expression.str.to_datetime(
+                        time_zone=plan["timezone"],
+                        strict=True,
+                    ).dt.replace_time_zone(None)
+                )
+            elif (
+                source_type == pl.String
+                and target["type"] == "timestamptz"
+            ):
+                expression = expression.str.to_datetime(
+                    time_zone=target["timezone"], strict=True
+                )
+            else:
+                expression = expression.cast(
+                    _cast_type(target), strict=True
+                )
+            frame = frame.with_columns(
+                expression.alias(operation["column"])
+            )
+        elif op == "deduplicate":
+            order = [
+                *operation["order_by"],
+                *sorted(
+                    name
+                    for name in frame.collect_schema().names()
+                    if name not in operation["order_by"]
+                ),
+            ]
+            descending = operation["keep"] == "last"
+            frame = (
+                frame.sort(
+                    order,
+                    descending=[descending] * len(order),
+                    nulls_last=True,
+                )
+                .unique(
+                    subset=operation["keys"],
+                    keep="first",
+                    maintain_order=True,
+                )
+            )
+            metrics["rows_deduplicated"] += before - _count(frame)
+        elif op == "mask":
+            if job["masking_policies"].get(
+                operation["policy_id"]
+            ) != operation["policy_kind"]:
+                raise ValueError("masking policy is not registered")
+            column = pl.col(operation["column"])
+            if operation["policy_kind"] == "redact":
+                masked = pl.when(column.is_null()).then(None).otherwise(
+                    pl.lit("***")
+                )
+            else:
+                masked = pl.when(column.is_null()).then(None).otherwise(
+                    pl.lit("***") + column.str.slice(-4)
+                )
+            frame = frame.with_columns(masked.alias(operation["column"]))
+        elif op == "aggregate":
+            aggregations = []
+            for aggregate in operation["aggregations"]:
+                expression = pl.col(aggregate["column"])
+                function = aggregate["function"]
+                if function == "count":
+                    expression = expression.count()
+                elif function == "sum":
+                    expression = expression.sum()
+                elif function == "min":
+                    expression = expression.min()
+                elif function == "max":
+                    expression = expression.max()
+                elif function == "mean":
+                    expression = expression.mean()
+                aggregations.append(
+                    expression.alias(aggregate["target"])
+                )
+            frame = frame.group_by(
+                operation["group_by"], maintain_order=True
+            ).agg(aggregations)
+            metrics["rows_aggregated"] += before - _count(frame)
+        elif op == "lookup_join":
+            lookup = pl.scan_parquet(
+                job["lookup_paths"][operation["lookup_binding_id"]]
+            )
+            _validate_lazy_schema(lookup, operation["lookup_fields"])
+            duplicates = (
+                lookup.group_by(operation["right_on"])
+                .len()
+                .filter(pl.col("len") > 1)
+                .select(pl.len())
+                .collect(engine="streaming")
+                .item()
+            )
+            if duplicates:
+                raise ValueError("lookup keys are not unique")
+            selected = list(operation["select"].values())
+            lookup = lookup.select(
+                list(
+                    dict.fromkeys(
+                        [*operation["right_on"], *selected]
+                    )
+                )
+            ).rename(
+                {
+                    source: target
+                    for target, source in operation["select"].items()
+                    if source not in operation["right_on"]
+                }
+            )
+            frame = frame.join(
+                lookup,
+                left_on=operation["left_on"],
+                right_on=operation["right_on"],
+                how=operation["how"],
+            )
+            after = _count(frame)
+            if after > before:
+                raise ValueError("lookup join expanded its input")
+            metrics["rows_join_dropped"] += before - after
+
+    frame = frame.select(
+        [field["name"] for field in plan["output_fields"]]
+    )
+    _validate_lazy_schema(frame, plan["output_fields"])
+    rows_out = _count(frame)
+    if rows_out > plan["resource_limits"]["max_rows"]:
+        raise ValueError("output row limit exceeded")
+    frame.sink_parquet(job["output_path"], engine="streaming")
+    return {
+        "worker_pid": os.getpid(),
+        "rows_in": rows_in,
+        "rows_out": rows_out,
+        **metrics,
+        "violation_count": sum(item["count"] for item in violations),
+        "violations": violations,
+    }
+
+
+def _polars_job_entry(
+    connection,
+    job: dict[str, Any],
+    memory_limit_bytes: int,
+) -> None:
+    # Initialize the Rust engine/thread pool before measuring the worker's
+    # immutable runtime baseline. The plan allowance applies only afterwards.
+    pl.DataFrame({"_warmup": [1]}).lazy().collect(engine="streaming")
+    process = psutil.Process()
+    memory = process.memory_info()
+    connection.send(
+        {
+            "kind": "ready",
+            "pid": os.getpid(),
+            "baseline_rss": memory.rss,
+        }
+    )
+    try:
+        _set_process_memory_limit(
+            baseline_vms=memory.vms,
+            memory_limit_bytes=memory_limit_bytes,
+        )
+        result = _execute_polars_job(job)
+        connection.send({"kind": "result", **result})
+    except (MemoryError, OSError):
+        with suppress(Exception):
+            connection.send({"kind": "resource_error"})
+    except BaseException as exc:
+        message = str(exc).lower()
+        kind = (
+            "resource_error"
+            if any(
+                token in message
+                for token in ("memory", "allocate", "allocation")
+            )
+            else "worker_error"
+        )
+        with suppress(Exception):
+            connection.send({"kind": kind})
+    finally:
+        connection.close()
+
+
+def _run_process(
+    *,
+    target,
+    args: tuple[Any, ...],
+    memory_limit_bytes: int,
+    timeout_seconds: float = 15.0,
+) -> 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
+    try:
+        while time.monotonic() < deadline:
+            if parent.poll(0.01):
+                try:
+                    message = parent.recv()
+                except EOFError as exc:
+                    raise PolarsWorkerResourceError(
+                        "Polars worker crossed its hard memory limit"
+                    ) from exc
+                kind = message.get("kind")
+                if kind == "ready":
+                    baseline_rss = int(message["baseline_rss"])
+                elif kind == "result":
+                    process.join(timeout=1)
+                    return message
+                elif kind == "resource_error":
+                    raise PolarsWorkerResourceError(
+                        "Polars worker exceeded its hard memory limit"
+                    )
+                else:
+                    raise PolarsWorkerError(
+                        "Polars worker execution failed"
+                    )
+            if baseline_rss is not None and process.is_alive():
+                try:
+                    rss = psutil.Process(process.pid).memory_info().rss
+                except (psutil.Error, ProcessLookupError):
+                    rss = baseline_rss
+                if rss - baseline_rss > memory_limit_bytes:
+                    process.kill()
+                    process.join(timeout=1)
+                    raise PolarsWorkerResourceError(
+                        "Polars worker exceeded its hard memory limit"
+                    )
+            if not process.is_alive():
+                process.join(timeout=1)
+                raise PolarsWorkerResourceError(
+                    "Polars worker crossed its hard memory limit"
+                )
+        process.kill()
+        process.join(timeout=1)
+        raise PolarsWorkerResourceError(
+            "Polars worker exceeded its bounded execution time"
+        )
+    finally:
+        parent.close()
+        if process.is_alive():
+            process.kill()
+        process.join(timeout=1)
+
+
+def run_isolated_memory_probe(
+    *,
+    allocate_bytes: int,
+    memory_limit_bytes: int,
+) -> dict[str, Any]:
+    """Exercise the same process boundary used by plan execution."""
+
+    return _run_process(
+        target=_memory_probe_entry,
+        args=(int(allocate_bytes),),
+        memory_limit_bytes=int(memory_limit_bytes),
+    )
+
+
+def execute_isolated_polars_plan(
+    job: dict[str, Any],
+    *,
+    memory_limit_bytes: int,
+) -> dict[str, Any]:
+    return _run_process(
+        target=_polars_job_entry,
+        args=(job,),
+        memory_limit_bytes=memory_limit_bytes,
+        timeout_seconds=60.0,
+    )

+ 121 - 411
app/runner/rule_polars.py

@@ -1,34 +1,47 @@
-"""Runner adapter that reconstructs allowlisted Polars LazyFrame operations."""
+"""Runner adapter for OS-isolated, allowlisted Polars execution."""
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
-from contextlib import suppress
-from decimal import Decimal
+import os
+import tempfile
+from contextlib import ExitStack, suppress
 from typing import Any
 from typing import Any
 
 
-import polars as pl
-
 from app.core.common.identifiers import ensure_governance_uid
 from app.core.common.identifiers import ensure_governance_uid
 from app.core.data_rules.compilers.polars import (
 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.nodes import NodeExecutionError
 from app.runner.nodes import NodeExecutionError
+from app.runner.polars_worker import (
+    PolarsWorkerError,
+    PolarsWorkerResourceError,
+    execute_isolated_polars_plan,
+)
 
 
-_TYPE_MAP = {
-    "boolean": pl.Boolean,
-    "date": pl.Date,
-    "double": pl.Float64,
-    "float": pl.Float32,
-    "integer": pl.Int64,
-    "string": pl.String,
-    "timestamp": pl.Datetime,
-}
 _IDEMPOTENCY = {
 _IDEMPOTENCY = {
     "deduplication_key",
     "deduplication_key",
     "partition_replace",
     "partition_replace",
     "upsert",
     "upsert",
 }
 }
+_PUBLIC_ARTIFACT_KEYS = (
+    "artifact_ref",
+    "digest",
+    "row_count",
+    "schema_hash",
+    "expires_at",
+)
+_RESULT_KEYS = (
+    "rows_in",
+    "rows_out",
+    "rows_rejected",
+    "rows_filtered",
+    "rows_deduplicated",
+    "rows_join_dropped",
+    "rows_aggregated",
+    "violation_count",
+    "violations",
+)
 
 
 
 
 def _uid(value: Any, label: str) -> str:
 def _uid(value: Any, label: str) -> str:
@@ -38,119 +51,19 @@ def _uid(value: Any, label: str) -> str:
         raise NodeExecutionError(f"{label} is invalid") from exc
         raise NodeExecutionError(f"{label} is invalid") from exc
 
 
 
 
-class _ExpressionCompiler:
-    def compile(self, ast: dict[str, Any]) -> pl.Expr:
-        kind = ast["kind"]
-        if kind == "identifier":
-            return pl.col(ast["name"])
-        if kind == "literal":
-            value = ast["value"]
-            if ast["type"] == "decimal":
-                value = Decimal(value)
-            return pl.lit(value)
-        if kind == "unary":
-            operand = self.compile(ast["operand"])
-            return ~operand if ast["operator"] == "!" else -operand
-        if kind == "binary":
-            left = self.compile(ast["left"])
-            right = self.compile(ast["right"])
-            return {
-                "||": lambda: left | right,
-                "&&": lambda: left & right,
-                "==": lambda: left == right,
-                "!=": lambda: left != right,
-                "<": lambda: left < right,
-                "<=": lambda: left <= right,
-                ">": lambda: left > right,
-                ">=": lambda: left >= right,
-                "+": lambda: left + right,
-                "-": lambda: left - right,
-                "*": lambda: left * right,
-                "/": lambda: left / right,
-                "%": lambda: left % right,
-            }[ast["operator"]]()
-        function = ast["function"]
-        arguments = [self.compile(item) for item in ast["arguments"]]
-        if function == "matches":
-            pattern = ast["arguments"][1]["value"]
-            return arguments[0].str.contains(pattern, strict=True)
-        if function == "lower":
-            return arguments[0].str.to_lowercase()
-        if function == "upper":
-            return arguments[0].str.to_uppercase()
-        if function == "trim":
-            return arguments[0].str.strip_chars()
-        if function == "length":
-            return arguments[0].str.len_chars()
-        if function == "coalesce":
-            return pl.coalesce(arguments)
-        if function == "date":
-            return arguments[0].cast(pl.Date, strict=True)
-        if function == "timestamp":
-            return arguments[0].cast(pl.Datetime, strict=True)
-        if function == "abs":
-            return arguments[0].abs()
-        raise NodeExecutionError("published Polars expression is unsupported")
-
-
-def _cast_type(field: dict[str, Any]) -> pl.DataType:
-    name = field["type"]
-    if name == "decimal":
-        precision = field.get("precision")
-        scale = field.get("scale")
-        if precision is None or scale is None:
-            raise NodeExecutionError(
-                "published decimal cast contract is incomplete"
-            )
-        return pl.Decimal(precision=precision, scale=scale)
-    if name == "timestamptz":
-        timezone = field.get("timezone")
-        if not timezone:
-            raise NodeExecutionError(
-                "published timestamptz cast contract is incomplete"
-            )
-        return pl.Datetime(time_zone=timezone)
-    dtype = _TYPE_MAP.get(name)
-    if dtype is None:
-        raise NodeExecutionError("published Polars cast type is unsupported")
-    return dtype
-
-
-def _bounded_materialize(
-    frame: pl.LazyFrame | pl.DataFrame,
-    limits: dict[str, int],
-    label: str,
-) -> pl.DataFrame:
-    try:
-        collected = (
-            frame.lazy() if isinstance(frame, pl.DataFrame) else frame
-        ).head(limits["max_rows"] + 1).collect(engine="streaming")
-    except Exception as exc:
-        raise NodeExecutionError(
-            f"published Polars {label} materialization failed"
-        ) from exc
-    if collected.height > limits["max_rows"]:
-        raise NodeExecutionError(
-            f"published Polars {label} exceeds its row limit"
-        )
-    if collected.estimated_size() > limits["memory_limit_bytes"]:
-        raise NodeExecutionError(
-            f"published Polars {label} exceeds its memory limit"
-        )
-    return collected
-
-
 def _resolve_artifact(
 def _resolve_artifact(
     resolver,
     resolver,
     *,
     *,
     binding_id: str,
     binding_id: str,
     binding_hash: str,
     binding_hash: str,
     correlation_id: str,
     correlation_id: str,
+    kind: str,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     try:
     try:
         artifact = resolver.resolve(
         artifact = resolver.resolve(
             binding_id=binding_id,
             binding_id=binding_id,
             correlation_id=correlation_id,
             correlation_id=correlation_id,
+            kind=kind,
         )
         )
     except Exception as exc:
     except Exception as exc:
         raise NodeExecutionError(
         raise NodeExecutionError(
@@ -161,6 +74,7 @@ def _resolve_artifact(
         or artifact.get("binding_hash") != binding_hash
         or artifact.get("binding_hash") != binding_hash
         or not isinstance(artifact.get("artifact_ref"), str)
         or not isinstance(artifact.get("artifact_ref"), str)
         or not isinstance(artifact.get("digest"), str)
         or not isinstance(artifact.get("digest"), str)
+        or not isinstance(artifact.get("schema_fields"), list)
     ):
     ):
         raise NodeExecutionError(
         raise NodeExecutionError(
             "published Polars artifact binding does not match"
             "published Polars artifact binding does not match"
@@ -169,7 +83,7 @@ def _resolve_artifact(
 
 
 
 
 class PolarsRulePlanAdapter:
 class PolarsRulePlanAdapter:
-    """Execute one digest-bound Polars plan and write one bounded artifact."""
+    """Stage bounded inputs and execute the plan in a hard-limited process."""
 
 
     def __init__(
     def __init__(
         self,
         self,
@@ -203,9 +117,13 @@ class PolarsRulePlanAdapter:
         if config.get("execution_plan_hash") != bound_polars_plan_hash(
         if config.get("execution_plan_hash") != bound_polars_plan_hash(
             normalized
             normalized
         ):
         ):
-            raise NodeExecutionError("published Polars rule plan hash does not match")
+            raise NodeExecutionError(
+                "published Polars rule plan hash does not match"
+            )
         if config.get("rule_version_id") != normalized["rule_version_id"]:
         if config.get("rule_version_id") != normalized["rule_version_id"]:
-            raise NodeExecutionError("published Polars rule id does not match")
+            raise NodeExecutionError(
+                "published Polars rule id does not match"
+            )
         idempotency = node.get("idempotency")
         idempotency = node.get("idempotency")
         if (
         if (
             node.get("type") != "rule.apply"
             node.get("type") != "rule.apply"
@@ -238,296 +156,99 @@ class PolarsRulePlanAdapter:
             binding_id=normalized["input_binding_id"],
             binding_id=normalized["input_binding_id"],
             binding_hash=normalized["input_binding_hash"],
             binding_hash=normalized["input_binding_hash"],
             correlation_id=correlation,
             correlation_id=correlation,
+            kind="input",
         )
         )
-        try:
-            frame = self.artifact_store.read(
-                source["artifact_ref"],
-                source["digest"],
-                expected_schema_fields=normalized["input_fields"],
-                limits=normalized["resource_limits"],
-            )
-        except ValueError as exc:
-            raise NodeExecutionError(
-                "published Polars input artifact is invalid"
-            ) from exc
         limits = normalized["resource_limits"]
         limits = normalized["resource_limits"]
-        initial = _bounded_materialize(frame, limits, "input")
-        rows_in = initial.height
-        frame = initial.lazy()
-        expressions = _ExpressionCompiler()
-        violations = []
-        rows_rejected = 0
-        rows_filtered = 0
-        rows_deduplicated = 0
-        rows_join_dropped = 0
-        rows_aggregated = 0
-        output_fields = {
-            field["name"]: field for field in normalized["output_fields"]
-        }
-
-        for index, operation in enumerate(normalized["operations"]):
-            op = operation["op"]
-            before = _bounded_materialize(
-                frame, limits, f"{op} input {index}"
-            ).height
-            if op == "normalize_text":
-                expression = pl.col(operation["column"])
-                if operation["trim"]:
-                    expression = expression.str.strip_chars()
-                if operation["lowercase"]:
-                    expression = expression.str.to_lowercase()
-                if operation["uppercase"]:
-                    expression = expression.str.to_uppercase()
-                frame = frame.with_columns(
-                    expression.alias(operation["column"])
-                )
-            elif op == "regex_replace":
-                frame = frame.with_columns(
-                    pl.col(operation["column"])
-                    .str.replace_all(
-                        operation["pattern"],
-                        operation["replacement"],
-                    )
-                    .alias(operation["column"])
-                )
-            elif op == "fill_null":
-                frame = frame.with_columns(
-                    pl.col(operation["column"])
-                    .fill_null(operation["value"])
-                    .alias(operation["column"])
-                )
-            elif op == "filter":
-                frame = frame.filter(
-                    expressions.compile(operation["expression_ast"]).fill_null(
-                        False
-                    )
-                )
-                after = _bounded_materialize(
-                    frame, limits, f"{op} output {index}"
-                )
-                rows_filtered += before - after.height
-                frame = after.lazy()
-                continue
-            elif op == "assert":
-                predicate = expressions.compile(
-                    operation["expression_ast"]
-                ).fill_null(False)
-                invalid = int(
-                    frame.select((~predicate).sum().alias("count"))
-                    .collect(engine="streaming")
-                    .item()
-                    or 0
-                )
-                violations.append(
-                    {"step_id": operation["step_id"], "count": invalid}
-                )
-                rows_rejected += invalid
-                frame = frame.filter(predicate)
-            elif op == "derive":
-                frame = frame.with_columns(
-                    expressions.compile(operation["expression_ast"]).alias(
-                        operation["target"]
-                    )
-                )
-            elif op == "map_values":
-                column = pl.col(operation["column"])
-                frame = frame.with_columns(
-                    column.replace_strict(
-                        operation["mapping"],
-                        default=column,
-                    ).alias(operation["column"])
-                )
-            elif op == "cast":
-                target_field = output_fields.get(operation["column"])
-                if target_field is None:
-                    target_field = {
-                        "type": operation["to"],
-                    }
-                column_expression = pl.col(operation["column"])
-                source_dtype = frame.collect_schema()[operation["column"]]
-                if (
-                    source_dtype == pl.String
-                    and target_field["type"] == "timestamptz"
-                ):
-                    column_expression = column_expression.str.to_datetime(
-                        time_zone=target_field["timezone"],
-                        strict=True,
-                    )
-                elif (
-                    source_dtype == pl.String
-                    and target_field["type"] == "timestamp"
-                ):
-                    column_expression = column_expression.str.to_datetime(
-                        strict=True,
-                    )
-                else:
-                    column_expression = column_expression.cast(
-                        _cast_type(target_field), strict=True
-                    )
-                frame = frame.with_columns(
-                    column_expression.alias(operation["column"])
-                )
-            elif op == "deduplicate":
-                order = [
-                    *operation["order_by"],
-                    *sorted(
-                        name
-                        for name in frame.collect_schema().names()
-                        if name not in operation["order_by"]
-                    ),
-                ]
-                descending = operation["keep"] == "last"
-                frame = (
-                    frame.sort(
-                        order,
-                        descending=[descending] * len(order),
-                        nulls_last=True,
-                    )
-                    .unique(
-                        subset=operation["keys"],
-                        keep="first",
-                        maintain_order=True,
+        output_path = None
+        try:
+            with ExitStack() as stack:
+                try:
+                    input_path = stack.enter_context(
+                        self.artifact_store.stage(
+                            source["artifact_ref"],
+                            source["digest"],
+                            expected_schema_fields=normalized[
+                                "input_fields"
+                            ],
+                            limits=limits,
+                        )
                     )
                     )
-                )
-                after = _bounded_materialize(
-                    frame, limits, f"{op} output {index}"
-                )
-                rows_deduplicated += before - after.height
-                frame = after.lazy()
-                continue
-            elif op == "mask":
-                if self.masking_policies.get(
-                    operation["policy_id"]
-                ) != operation["policy_kind"]:
+                    lookup_paths = {}
+                    for operation in normalized["operations"]:
+                        if operation["op"] != "lookup_join":
+                            continue
+                        lookup_artifact = _resolve_artifact(
+                            self.artifact_resolver,
+                            binding_id=operation[
+                                "lookup_binding_id"
+                            ],
+                            binding_hash=operation[
+                                "lookup_binding_hash"
+                            ],
+                            correlation_id=correlation,
+                            kind="lookup",
+                        )
+                        lookup_paths[
+                            operation["lookup_binding_id"]
+                        ] = stack.enter_context(
+                            self.artifact_store.stage(
+                                lookup_artifact["artifact_ref"],
+                                lookup_artifact["digest"],
+                                expected_schema_fields=operation[
+                                    "lookup_fields"
+                                ],
+                                limits=limits,
+                            )
+                        )
+                except ValueError as exc:
                     raise NodeExecutionError(
                     raise NodeExecutionError(
-                        "published masking policy is not registered"
-                    )
-                column = pl.col(operation["column"])
-                if operation["policy_kind"] == "redact":
-                    masked = pl.when(column.is_null()).then(None).otherwise(
-                        pl.lit("***")
-                    )
-                elif operation["policy_kind"] == "preserve_last_4":
-                    masked = pl.when(column.is_null()).then(None).otherwise(
-                        pl.lit("***") + column.str.slice(-4)
+                        "published Polars input artifact is invalid"
+                    ) from exc
+                with tempfile.NamedTemporaryFile(
+                    prefix="dataops-polars-worker-output-",
+                    suffix=".parquet",
+                    delete=False,
+                ) as handle:
+                    output_path = handle.name
+                try:
+                    worker_result = execute_isolated_polars_plan(
+                        {
+                            "plan": normalized,
+                            "input_path": input_path,
+                            "lookup_paths": lookup_paths,
+                            "output_path": output_path,
+                            "masking_policies": self.masking_policies,
+                        },
+                        memory_limit_bytes=limits[
+                            "memory_limit_bytes"
+                        ],
                     )
                     )
-                else:
+                except PolarsWorkerResourceError as exc:
                     raise NodeExecutionError(
                     raise NodeExecutionError(
-                        "published masking policy is unsupported"
-                    )
-                frame = frame.with_columns(masked.alias(operation["column"]))
-            elif op == "aggregate":
-                aggregations = []
-                for aggregate in operation["aggregations"]:
-                    expression = pl.col(aggregate["column"])
-                    function = aggregate["function"]
-                    if function == "count":
-                        expression = expression.count()
-                    elif function == "sum":
-                        expression = expression.sum()
-                    elif function == "min":
-                        expression = expression.min()
-                    elif function == "max":
-                        expression = expression.max()
-                    elif function == "mean":
-                        expression = expression.mean()
-                    aggregations.append(
-                        expression.alias(aggregate["target"])
-                    )
-                frame = frame.group_by(
-                    operation["group_by"], maintain_order=True
-                ).agg(aggregations)
-                after = _bounded_materialize(
-                    frame, limits, f"{op} output {index}"
-                )
-                rows_aggregated += before - after.height
-                frame = after.lazy()
-                continue
-            elif op == "lookup_join":
-                lookup_artifact = _resolve_artifact(
-                    self.artifact_resolver,
-                    binding_id=operation["lookup_binding_id"],
-                    binding_hash=operation["lookup_binding_hash"],
-                    correlation_id=correlation,
-                )
+                        "published Polars worker exceeded its hard memory limit"
+                    ) from exc
+                except PolarsWorkerError as exc:
+                    raise NodeExecutionError(
+                        "published Polars worker execution failed"
+                    ) from exc
                 try:
                 try:
-                    lookup = self.artifact_store.read(
-                        lookup_artifact["artifact_ref"],
-                        lookup_artifact["digest"],
-                        expected_schema_fields=operation["lookup_fields"],
+                    artifact = self.artifact_store.write_path(
+                        output_path,
+                        correlation,
+                        self.artifact_ttl_seconds,
+                        schema_fields=normalized["output_fields"],
                         limits=limits,
                         limits=limits,
                     )
                     )
                 except ValueError as exc:
                 except ValueError as exc:
                     raise NodeExecutionError(
                     raise NodeExecutionError(
-                        "published Polars lookup artifact is invalid"
+                        "published Polars output artifact write failed"
                     ) from exc
                     ) from exc
-                lookup = _bounded_materialize(
-                    lookup, limits, f"lookup input {index}"
-                ).lazy()
-                duplicate_count = (
-                    lookup.group_by(operation["right_on"])
-                    .len()
-                    .filter(pl.col("len") > 1)
-                    .select(pl.len())
-                    .collect(engine="streaming")
-                    .item()
-                )
-                if duplicate_count:
-                    raise NodeExecutionError(
-                        "published Polars lookup keys are not unique"
-                    )
-                selected_sources = list(operation["select"].values())
-                lookup = lookup.select(
-                    list(dict.fromkeys(
-                        [*operation["right_on"], *selected_sources]
-                    ))
-                ).rename(
-                    {
-                        source_name: target_name
-                        for target_name, source_name in operation[
-                            "select"
-                        ].items()
-                        if source_name not in operation["right_on"]
-                    }
-                )
-                frame = frame.join(
-                    lookup,
-                    left_on=operation["left_on"],
-                    right_on=operation["right_on"],
-                    how=operation["how"],
-                )
-                after = _bounded_materialize(
-                    frame, limits, f"{op} output {index}"
-                )
-                if after.height > before:
-                    raise NodeExecutionError(
-                        "published Polars lookup join expanded its input"
-                    )
-                rows_join_dropped += before - after.height
-                frame = after.lazy()
-                continue
-
-            frame = _bounded_materialize(
-                frame, limits, f"{op} output {index}"
-            ).lazy()
-
-        output_names = [field["name"] for field in normalized["output_fields"]]
-        frame = frame.select(output_names)
-        bounded = _bounded_materialize(frame, limits, "output")
-        try:
-            artifact = self.artifact_store.write(
-                bounded.lazy(),
-                correlation,
-                self.artifact_ttl_seconds,
-                schema_fields=normalized["output_fields"],
-                limits=limits,
-            )
-        except ValueError as exc:
-            raise NodeExecutionError(
-                "published Polars output artifact write failed"
-            ) from exc
+        finally:
+            if output_path is not None:
+                with suppress(FileNotFoundError):
+                    os.unlink(output_path)
         try:
         try:
-            self.artifact_resolver.register(
+            registered = self.artifact_resolver.register(
                 binding_id=normalized["output_binding_id"],
                 binding_id=normalized["output_binding_id"],
                 correlation_id=correlation,
                 correlation_id=correlation,
                 artifact=artifact,
                 artifact=artifact,
@@ -535,24 +256,13 @@ class PolarsRulePlanAdapter:
                 binding_hash=normalized["output_binding_hash"],
                 binding_hash=normalized["output_binding_hash"],
             )
             )
         except Exception as exc:
         except Exception as exc:
-            delete = getattr(self.artifact_store, "delete", None)
-            if callable(delete):
-                with suppress(Exception):
-                    delete(artifact["artifact_ref"])
+            with suppress(Exception):
+                self.artifact_store.delete(artifact["artifact_ref"])
             raise NodeExecutionError(
             raise NodeExecutionError(
                 "published Polars output artifact registration failed"
                 "published Polars output artifact registration failed"
             ) from exc
             ) from exc
-        violation_count = sum(item["count"] for item in violations)
         return {
         return {
-            **artifact,
-            "rows_in": rows_in,
-            "rows_out": bounded.height,
-            "rows_rejected": rows_rejected,
-            "rows_filtered": rows_filtered,
-            "rows_deduplicated": rows_deduplicated,
-            "rows_join_dropped": rows_join_dropped,
-            "rows_aggregated": rows_aggregated,
-            "violation_count": violation_count,
-            "violations": violations,
+            **{key: registered[key] for key in _PUBLIC_ARTIFACT_KEYS},
+            **{key: worker_result[key] for key in _RESULT_KEYS},
             "commit_outcome": "committed",
             "commit_outcome": "committed",
         }
         }

+ 15 - 0
docs/security/cyclonedx-1.5-schema/README.md

@@ -0,0 +1,15 @@
+# CycloneDX 1.5 JSON Schema
+
+These fixed validation inputs were retrieved from the official CycloneDX
+schema host on 2026-07-23:
+
+- `https://cyclonedx.org/schema/bom-1.5.schema.json`
+  (`sha256:a00fcba23a44b72179ac1f288be4c3529b59f6e1b3719709a40685f177516b46`)
+- `https://cyclonedx.org/schema/spdx.schema.json`
+  (`sha256:c87aa7bb5eb503d40b52ec6bf00de8045df15da7a13cea48d290cf6d36a8d2ea`)
+- `https://cyclonedx.org/schema/jsf-0.82.schema.json`
+  (`sha256:2faf5eb3651f2ae5f46091a131770d8d847bbd121139d19c85fc7051bfa58c46`)
+
+The primary schema states that the CycloneDX JSON schema is published under
+the Apache License 2.0. The files are vendored so SBOM validation is
+deterministic and does not require network access.

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
docs/security/cyclonedx-1.5-schema/bom-1.5.schema.json


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
docs/security/cyclonedx-1.5-schema/jsf-0.82.schema.json


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
docs/security/cyclonedx-1.5-schema/spdx.schema.json


+ 148 - 6
docs/security/data-rule-runtime-sbom.json

@@ -5,27 +5,169 @@
   "metadata": {
   "metadata": {
     "component": {
     "component": {
       "type": "application",
       "type": "application",
+      "bom-ref": "dataops-governed-rule-runtime",
       "name": "dataops-governed-rule-runtime"
       "name": "dataops-governed-rule-runtime"
     }
     }
   },
   },
   "components": [
   "components": [
     {
     {
       "type": "library",
       "type": "library",
+      "bom-ref": "pkg:pypi/polars@1.42.1",
       "name": "polars",
       "name": "polars",
       "version": "1.42.1",
       "version": "1.42.1",
       "purl": "pkg:pypi/polars@1.42.1",
       "purl": "pkg:pypi/polars@1.42.1",
-      "license": "MIT",
-      "officialSource": "https://github.com/pola-rs/polars",
-      "purpose": "Bounded lazy/dataframe execution for governed batch rules"
+      "licenses": [
+        {
+          "license": {
+            "id": "MIT"
+          }
+        }
+      ],
+      "externalReferences": [
+        {
+          "type": "vcs",
+          "url": "https://github.com/pola-rs/polars"
+        }
+      ],
+      "properties": [
+        {
+          "name": "dataops:purpose",
+          "value": "Python API for governed Polars batch execution"
+        }
+      ]
     },
     },
     {
     {
       "type": "library",
       "type": "library",
+      "bom-ref": "pkg:pypi/polars-runtime-32@1.42.1",
+      "name": "polars-runtime-32",
+      "version": "1.42.1",
+      "purl": "pkg:pypi/polars-runtime-32@1.42.1",
+      "licenses": [
+        {
+          "license": {
+            "id": "MIT"
+          }
+        }
+      ],
+      "externalReferences": [
+        {
+          "type": "vcs",
+          "url": "https://github.com/pola-rs/polars"
+        }
+      ],
+      "properties": [
+        {
+          "name": "dataops:purpose",
+          "value": "Native 32-bit Polars runtime wheel"
+        }
+      ]
+    },
+    {
+      "type": "library",
+      "bom-ref": "pkg:pypi/pyarrow@21.0.0",
+      "name": "pyarrow",
+      "version": "21.0.0",
+      "purl": "pkg:pypi/pyarrow@21.0.0",
+      "licenses": [
+        {
+          "license": {
+            "id": "Apache-2.0"
+          }
+        }
+      ],
+      "externalReferences": [
+        {
+          "type": "vcs",
+          "url": "https://github.com/apache/arrow"
+        }
+      ],
+      "properties": [
+        {
+          "name": "dataops:purpose",
+          "value": "Trusted Parquet footer and row-group resource preflight"
+        }
+      ]
+    },
+    {
+      "type": "library",
+      "bom-ref": "pkg:pypi/psutil@5.9.8",
+      "name": "psutil",
+      "version": "5.9.8",
+      "purl": "pkg:pypi/psutil@5.9.8",
+      "licenses": [
+        {
+          "license": {
+            "id": "BSD-3-Clause"
+          }
+        }
+      ],
+      "externalReferences": [
+        {
+          "type": "vcs",
+          "url": "https://github.com/giampaolo/psutil"
+        }
+      ],
+      "properties": [
+        {
+          "name": "dataops:purpose",
+          "value": "Isolated worker RSS enforcement and process monitoring"
+        }
+      ]
+    },
+    {
+      "type": "library",
+      "bom-ref": "pkg:pypi/minio@7.2.10",
       "name": "minio",
       "name": "minio",
       "version": "7.2.10",
       "version": "7.2.10",
       "purl": "pkg:pypi/minio@7.2.10",
       "purl": "pkg:pypi/minio@7.2.10",
-      "license": "Apache-2.0",
-      "officialSource": "https://github.com/minio/minio-py",
-      "purpose": "Digest-bound Parquet artifact transport and metadata"
+      "licenses": [
+        {
+          "license": {
+            "id": "Apache-2.0"
+          }
+        }
+      ],
+      "externalReferences": [
+        {
+          "type": "vcs",
+          "url": "https://github.com/minio/minio-py"
+        }
+      ],
+      "properties": [
+        {
+          "name": "dataops:purpose",
+          "value": "Digest-bound Parquet artifact transport"
+        }
+      ]
+    }
+  ],
+  "dependencies": [
+    {
+      "ref": "dataops-governed-rule-runtime",
+      "dependsOn": [
+        "pkg:pypi/minio@7.2.10",
+        "pkg:pypi/polars@1.42.1",
+        "pkg:pypi/psutil@5.9.8",
+        "pkg:pypi/pyarrow@21.0.0"
+      ]
+    },
+    {
+      "ref": "pkg:pypi/polars@1.42.1",
+      "dependsOn": [
+        "pkg:pypi/polars-runtime-32@1.42.1"
+      ]
+    },
+    {
+      "ref": "pkg:pypi/polars-runtime-32@1.42.1"
+    },
+    {
+      "ref": "pkg:pypi/pyarrow@21.0.0"
+    },
+    {
+      "ref": "pkg:pypi/psutil@5.9.8"
+    },
+    {
+      "ref": "pkg:pypi/minio@7.2.10"
     }
     }
   ]
   ]
 }
 }

+ 5 - 3
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_digest)
+            UNIQUE (correlation_id, binding_id, artifact_kind)
         );
         );
         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,5 +36,7 @@ def upgrade() -> None:
 
 
 
 
 def downgrade() -> None:
 def downgrade() -> None:
-    # Runtime handoff evidence is immutable and intentionally retained.
-    pass
+    raise RuntimeError(
+        "rule run artifact catalog is forward-only and cannot downgrade "
+        "without violating immutable runtime handoff evidence"
+    )

+ 1 - 0
requirements.txt

@@ -12,6 +12,7 @@ psycopg2-binary==2.9.9
 PyMySQL==1.1.1
 PyMySQL==1.1.1
 sqlglot==30.13.0
 sqlglot==30.13.0
 polars==1.42.1
 polars==1.42.1
+pyarrow==21.0.0
 neo4j==5.26.0
 neo4j==5.26.0
 argon2-cffi==25.1.0
 argon2-cffi==25.1.0
 PyJWT==2.10.1
 PyJWT==2.10.1

+ 68 - 0
tests/core/data_rules/test_polars_compiler.py

@@ -592,6 +592,74 @@ def test_polars_plan_carries_timezone_and_complete_decimal_temporal_semantics():
     )["timezone"] == "Asia/Shanghai"
     )["timezone"] == "Asia/Shanghai"
 
 
 
 
+def test_polars_cast_operations_carry_complete_typed_targets():
+    from app.core.data_rules.compilers.polars import (
+        PolarsRuleCompiler,
+        validate_bound_polars_plan,
+    )
+
+    input_schema = _schema(
+        "bd:finance:raw",
+        [("amount", "string", False), ("occurred_at", "string", False)],
+    )
+    output_schema = _schema(
+        "bd:finance:clean",
+        [
+            ("amount", "decimal", False),
+            ("occurred_at", "timestamptz", False),
+        ],
+    )
+    output_schema["fields"][0].update({"precision": 12, "scale": 2})
+    output_schema["fields"][1]["timezone"] = "Asia/Shanghai"
+    output_schema["schema_hash"] = canonical_schema_hash(
+        output_schema["fields"]
+    )
+    source = _binding(input_schema, access_mode="read")
+    target = _binding(output_schema, access_mode="write")
+    rule = _published_rule(
+        input_schema,
+        output_schema,
+        [
+            {
+                "id": "cast_amount",
+                "op": "cast",
+                "column": "amount",
+                "to": "decimal",
+                "on_error": "fail",
+            },
+            {
+                "id": "cast_occurred_at",
+                "op": "cast",
+                "column": "occurred_at",
+                "to": "timestamptz",
+                "on_error": "fail",
+            },
+        ],
+    )
+    plan = PolarsRuleCompiler().compile(
+        rule_version=rule,
+        input_schema=input_schema,
+        output_schema=output_schema,
+        input_binding=source,
+        output_binding=target,
+        backend=_backend(),
+    )["plan"]
+
+    amount, occurred_at = plan["operations"]
+    assert amount["target_field"] == {
+        "name": "amount",
+        "type": "decimal",
+        "nullable": False,
+        "precision": 12,
+        "scale": 2,
+    }
+    assert occurred_at["target_field"]["timezone"] == "Asia/Shanghai"
+
+    del amount["target_field"]
+    with pytest.raises(ValueError, match="closed shape|target_field"):
+        validate_bound_polars_plan(plan)
+
+
 def test_shared_polars_semantic_validator_rejects_tampered_expression_type():
 def test_shared_polars_semantic_validator_rejects_tampered_expression_type():
     from app.core.data_rules.compilers.polars import (
     from app.core.data_rules.compilers.polars import (
         validate_bound_polars_plan,
         validate_bound_polars_plan,

+ 106 - 6
tests/integration/test_data_rule_polars_execution.py

@@ -5,6 +5,7 @@ import re
 from pathlib import Path
 from pathlib import Path
 
 
 import polars as pl
 import polars as pl
+import pytest
 from minio import Minio
 from minio import Minio
 from sqlalchemy import create_engine, text
 from sqlalchemy import create_engine, text
 
 
@@ -105,7 +106,7 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution():
         bucket=bucket,
         bucket=bucket,
         max_artifact_bytes=4 * 1024 * 1024,
         max_artifact_bytes=4 * 1024 * 1024,
         max_rows=1_000,
         max_rows=1_000,
-        memory_limit_bytes=16 * 1024 * 1024,
+        memory_limit_bytes=256 * 1024 * 1024,
         max_ttl_seconds=3600,
         max_ttl_seconds=3600,
     )
     )
     correlation_id = new_governance_uid()
     correlation_id = new_governance_uid()
@@ -119,6 +120,7 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution():
     deployment_id = new_governance_uid()
     deployment_id = new_governance_uid()
     component_binding_id = new_governance_uid()
     component_binding_id = new_governance_uid()
     plan_id = new_governance_uid()
     plan_id = new_governance_uid()
+    ledger_jti = None
 
 
     try:
     try:
         with postgres.begin() as connection:
         with postgres.begin() as connection:
@@ -283,7 +285,7 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution():
             backend={
             backend={
                 "max_rows": 1_000,
                 "max_rows": 1_000,
                 "max_artifact_bytes": 4 * 1024 * 1024,
                 "max_artifact_bytes": 4 * 1024 * 1024,
-                "memory_limit_bytes": 16 * 1024 * 1024,
+                "memory_limit_bytes": 256 * 1024 * 1024,
                 "masking_policies": {},
                 "masking_policies": {},
                 "lookup_bindings": {
                 "lookup_bindings": {
                     lookup_binding["id"]: {
                     lookup_binding["id"]: {
@@ -589,20 +591,110 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution():
             )
             )
         )
         )
         assert repeated["rows_out"] == 2
         assert repeated["rows_out"] == 2
+        assert repeated["artifact_ref"] == result["artifact_ref"]
+        assert "schema_fields" not in result
+
+        from app.runner.api import create_runner_app
+        from app.runner.auth import TaskTokenIssuer, TaskTokenVerifier
+        from app.runner.ledger import PostgresTaskLedger
+        from app.runner.nodes import NodeRegistry
+
+        task_secret = "task5-real-http-secret-value-32-bytes"
+        verifier = TaskTokenVerifier(task_secret)
+        task_token = TaskTokenIssuer(task_secret).issue(
+            task_uid=new_governance_uid(),
+            dataflow_uid=dataflow_uid,
+            workflow_version=1,
+            correlation_id=correlation_id,
+            node=node,
+            write_authorized=True,
+        )
+        ledger_jti = verifier.verify(task_token, node=node).jti
+        runner_app = create_runner_app(
+            verifier=verifier,
+            ledger=PostgresTaskLedger(platform),
+            registry=NodeRegistry({"rule.apply": executor}),
+        )
+        with runner_app.test_client() as client:
+            http_result = client.post(
+                "/v1/tasks/execute",
+                json={
+                    "task_token": task_token,
+                    "node": node,
+                    "parameters": {},
+                },
+            )
+            replay = client.post(
+                "/v1/tasks/execute",
+                json={
+                    "task_token": task_token,
+                    "node": node,
+                    "parameters": {},
+                },
+            )
+        assert http_result.status_code == 200
+        assert http_result.get_json()["result"]["artifact_ref"] == result[
+            "artifact_ref"
+        ]
+        assert replay.status_code == 409
+        ledger_record = PostgresTaskLedger(platform).get(ledger_jti)
+        assert ledger_record is not None
+        assert ledger_record.status == "success"
+        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"],
+        )
+        with pytest.raises(ValueError, match="immutable|digest"):
+            resolver.register(
+                binding_id=output_binding["id"],
+                binding_hash=compiled["plan"]["output_binding_hash"],
+                correlation_id=correlation_id,
+                artifact=conflict,
+                kind="output",
+            )
+        assert not any(
+            item.object_name
+            == conflict["artifact_ref"].split("/", 3)[-1]
+            for item in minio.list_objects(
+                bucket, prefix=prefix, recursive=True
+            )
+        )
         with platform.connect() as connection:
         with platform.connect() as connection:
-            catalog_count = connection.execute(
+            catalog_rows = connection.execute(
                 text(
                 text(
                     """
                     """
-                    SELECT COUNT(*)
+                    SELECT artifact_kind, artifact_ref
                     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
                     """
                     """
                 ),
                 ),
                 {"correlation_id": correlation_id},
                 {"correlation_id": correlation_id},
-            ).scalar_one()
+            ).mappings().all()
         # 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 catalog_count == 3
+        assert len(catalog_rows) == 3
+        assert next(
+            row["artifact_ref"]
+            for row in catalog_rows
+            if row["artifact_kind"] == "output"
+        ) == result["artifact_ref"]
+        assert len(
+            list(minio.list_objects(bucket, prefix=prefix, recursive=True))
+        ) == 3
     finally:
     finally:
         for item in list(
         for item in list(
             minio.list_objects(bucket, prefix=prefix, recursive=True)
             minio.list_objects(bucket, prefix=prefix, recursive=True)
@@ -616,6 +708,14 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution():
         with mysql.begin() as connection:
         with mysql.begin() as connection:
             connection.execute(text(f"DROP TABLE IF EXISTS {segment_table}"))
             connection.execute(text(f"DROP TABLE IF EXISTS {segment_table}"))
         with platform.begin() as connection:
         with platform.begin() as connection:
+            if ledger_jti is not None:
+                connection.execute(
+                    text(
+                        "DELETE FROM public.runner_task_executions "
+                        "WHERE token_jti = CAST(:jti AS uuid)"
+                    ),
+                    {"jti": ledger_jti},
+                )
             connection.execute(
             connection.execute(
                 text(
                 text(
                     "DELETE FROM public.rule_run_artifacts "
                     "DELETE FROM public.rule_run_artifacts "

+ 240 - 4
tests/runner/test_artifacts.py

@@ -1,6 +1,7 @@
 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
 
 
@@ -112,8 +113,27 @@ def test_artifact_store_generates_key_and_round_trips_digest_bound_lazyframe():
     assert artifact["schema_hash"]
     assert artifact["schema_hash"]
     assert artifact["expires_at"].endswith("Z")
     assert artifact["expires_at"].endswith("Z")
     assert "dataops-test" not in repr(artifact)
     assert "dataops-test" not in repr(artifact)
-    assert store.describe(artifact["artifact_ref"]) == artifact
-    frame = store.read(artifact["artifact_ref"], artifact["digest"])
+    assert store.describe(artifact["artifact_ref"]) == {
+        key: artifact[key]
+        for key in (
+            "artifact_ref",
+            "digest",
+            "row_count",
+            "schema_hash",
+            "expires_at",
+        )
+    }
+    stored = next(iter(client.objects.values()))
+    assert "x-amz-meta-schema-contract" not in stored["metadata"]
+    assert sum(
+        len(key) + len(value)
+        for key, value in stored["metadata"].items()
+    ) <= 2_048
+    frame = store.read(
+        artifact["artifact_ref"],
+        artifact["digest"],
+        expected_schema_fields=artifact["schema_fields"],
+    )
     assert isinstance(frame, pl.LazyFrame)
     assert isinstance(frame, pl.LazyFrame)
     assert frame.collect().to_dicts() == [
     assert frame.collect().to_dicts() == [
         {"customer_id": 1, "name": "Alice"},
         {"customer_id": 1, "name": "Alice"},
@@ -163,7 +183,11 @@ def test_artifact_store_rejects_tampered_digest_schema_ttl_and_content(
     mutation(client.objects[("dataops-rules", key)])
     mutation(client.objects[("dataops-rules", key)])
 
 
     with pytest.raises(ValueError, match=message):
     with pytest.raises(ValueError, match=message):
-        store.read(artifact["artifact_ref"], artifact["digest"])
+        store.read(
+            artifact["artifact_ref"],
+            artifact["digest"],
+            expected_schema_fields=artifact["schema_fields"],
+        )
 
 
 
 
 def test_artifact_store_rejects_rows_size_ttl_and_unowned_references():
 def test_artifact_store_rejects_rows_size_ttl_and_unowned_references():
@@ -186,6 +210,9 @@ def test_artifact_store_rejects_rows_size_ttl_and_unowned_references():
         store.read(
         store.read(
             "minio://other-bucket/rules/unsafe/value.parquet",
             "minio://other-bucket/rules/unsafe/value.parquet",
             "0" * 64,
             "0" * 64,
+            expected_schema_fields=[
+                {"name": "id", "type": "integer", "nullable": True}
+            ],
         )
         )
 
 
 
 
@@ -224,6 +251,7 @@ def test_artifact_store_applies_plan_limits_before_download_and_serialization():
         store.read(
         store.read(
             artifact["artifact_ref"],
             artifact["artifact_ref"],
             artifact["digest"],
             artifact["digest"],
+            expected_schema_fields=artifact["schema_fields"],
             limits={
             limits={
                 "max_rows": 1,
                 "max_rows": 1,
                 "max_artifact_bytes": 1024 * 1024,
                 "max_artifact_bytes": 1024 * 1024,
@@ -245,6 +273,74 @@ def test_artifact_store_applies_plan_limits_before_download_and_serialization():
         )
         )
 
 
 
 
+def test_artifact_stage_streams_to_tempfile_and_preflights_parquet_footer(
+):
+    import inspect
+
+    from app.runner.artifacts import ArtifactStore
+
+    client = FakeMinio()
+    store = _store(client)
+    correlation_id = new_governance_uid()
+    artifact = store.write(
+        pl.DataFrame({"id": [1, 2]}),
+        correlation_id,
+        60,
+    )
+
+    assert "BytesIO" not in inspect.getsource(ArtifactStore.stage)
+
+    with store.stage(
+        artifact["artifact_ref"],
+        artifact["digest"],
+        expected_schema_fields=artifact["schema_fields"],
+    ) as staged:
+        assert pl.scan_parquet(staged).collect().height == 2
+
+
+def test_artifact_stage_rejects_compression_bomb_from_footer_before_scan():
+    from app.runner.artifacts import ArtifactStore
+
+    client = FakeMinio()
+    store = ArtifactStore(
+        client,
+        bucket="dataops-rules",
+        max_artifact_bytes=4 * 1024 * 1024,
+        max_rows=1_000,
+        memory_limit_bytes=64 * 1024 * 1024,
+        max_ttl_seconds=3600,
+    )
+    correlation_id = new_governance_uid()
+    artifact = store.write(
+        pl.DataFrame(
+            {
+                "payload": [
+                    f"{index}-" + ("compressible-value-" * 10_000)
+                    for index in range(100)
+                ]
+            }
+        ),
+        correlation_id,
+        60,
+    )
+    calls_before = len(client.get_calls)
+
+    with pytest.raises(
+        ValueError, match="uncompressed|footer"
+    ), store.stage(
+        artifact["artifact_ref"],
+        artifact["digest"],
+        expected_schema_fields=artifact["schema_fields"],
+        limits={
+            "max_rows": 1_000,
+            "max_artifact_bytes": 4 * 1024 * 1024,
+            "memory_limit_bytes": 1024 * 1024,
+        },
+    ):
+        raise AssertionError("compression bomb must not be exposed")
+    assert len(client.get_calls) == calls_before + 1
+
+
 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)
@@ -346,6 +442,11 @@ class _Rows:
     def one_or_none(self):
     def one_or_none(self):
         return self.row
         return self.row
 
 
+    def all(self):
+        if self.row is None:
+            return []
+        return self.row if isinstance(self.row, list) else [self.row]
+
 
 
 class _Connection:
 class _Connection:
     def __init__(self, engine):
     def __init__(self, engine):
@@ -401,6 +502,7 @@ def test_postgres_artifact_resolver_uses_catalog_and_rechecks_binding_hash():
     resolved = PostgresArtifactResolver(engine, store).resolve(
     resolved = PostgresArtifactResolver(engine, store).resolve(
         binding_id=binding_id,
         binding_id=binding_id,
         correlation_id=correlation_id,
         correlation_id=correlation_id,
+        kind="input",
     )
     )
 
 
     assert resolved["artifact_ref"] == artifact["artifact_ref"]
     assert resolved["artifact_ref"] == artifact["artifact_ref"]
@@ -414,6 +516,7 @@ def test_postgres_artifact_resolver_uses_catalog_and_rechecks_binding_hash():
         PostgresArtifactResolver(engine, store).resolve(
         PostgresArtifactResolver(engine, store).resolve(
             binding_id=binding_id,
             binding_id=binding_id,
             correlation_id=new_governance_uid(),
             correlation_id=new_governance_uid(),
+            kind="input",
         )
         )
 
 
 
 
@@ -440,7 +543,14 @@ def test_postgres_artifact_resolver_attests_and_registers_stable_handoff():
         if "INSERT INTO public.rule_run_artifacts" in sql:
         if "INSERT INTO public.rule_run_artifacts" in sql:
             assert parameters["artifact_digest"] == artifact["digest"]
             assert parameters["artifact_digest"] == artifact["digest"]
             assert parameters["schema_fields"]
             assert parameters["schema_fields"]
-            return None
+            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)
         raise AssertionError(sql)
 
 
     engine = _Engine(handler)
     engine = _Engine(handler)
@@ -466,3 +576,129 @@ def test_postgres_artifact_resolver_attests_and_registers_stable_handoff():
         "INSERT INTO public.rule_run_artifacts" in sql
         "INSERT INTO public.rule_run_artifacts" in sql
         for sql, _parameters in engine.calls
         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():
+    from app.runner.artifacts import PostgresArtifactResolver
+
+    store = _store(FakeMinio())
+    correlation_id = new_governance_uid()
+    artifact = store.write(
+        pl.DataFrame({"id": [1]}),
+        correlation_id,
+        60,
+    )
+    row_id = new_governance_uid()
+    deleted_ids = []
+
+    def handler(sql, parameters):
+        if "DELETE FROM public.rule_run_artifacts" in sql:
+            deleted_ids.append(parameters["id"])
+            return None
+        if "FROM public.rule_run_artifacts" in sql:
+            assert "FOR UPDATE SKIP LOCKED" in sql
+            assert parameters["limit"] == 10
+            return [{"id": row_id, "artifact_ref": artifact["artifact_ref"]}]
+        raise AssertionError(sql)
+
+    resolver = PostgresArtifactResolver(_Engine(handler), store)
+
+    assert resolver.cleanup_expired(limit=10) == 1
+    assert deleted_ids == [row_id]
+    assert not store.client.objects

+ 27 - 1
tests/runner/test_bootstrap.py

@@ -1,6 +1,10 @@
 import pytest
 import pytest
+from flask import Flask
 
 
-from app.runner.bootstrap import runner_settings_from_env
+from app.runner.bootstrap import (
+    register_artifact_cleanup_cli,
+    runner_settings_from_env,
+)
 
 
 
 
 def test_runner_settings_require_signing_secret_and_explicit_dependencies(
 def test_runner_settings_require_signing_secret_and_explicit_dependencies(
@@ -45,3 +49,25 @@ def test_runner_refuses_to_start_without_a_task_token_secret(monkeypatch):
 
 
     with pytest.raises(ValueError, match="RUNNER_TASK_TOKEN_SECRET"):
     with pytest.raises(ValueError, match="RUNNER_TASK_TOKEN_SECRET"):
         runner_settings_from_env()
         runner_settings_from_env()
+
+
+def test_runner_registers_bounded_artifact_cleanup_cli():
+    class Resolver:
+        def __init__(self):
+            self.limit = None
+
+        def cleanup_expired(self, *, limit):
+            self.limit = limit
+            return 3
+
+    resolver = Resolver()
+    app = Flask("runner-cleanup-test")
+    register_artifact_cleanup_cli(app, resolver)
+
+    result = app.test_cli_runner().invoke(
+        args=["cleanup-rule-artifacts", "--limit", "25"]
+    )
+
+    assert result.exit_code == 0
+    assert result.output.strip() == "removed 3 expired rule artifacts"
+    assert resolver.limit == 25

+ 231 - 0
tests/runner/test_polars_worker.py

@@ -0,0 +1,231 @@
+from __future__ import annotations
+
+import os
+
+import polars as pl
+import pytest
+
+from tests.core.data_rules.test_polars_compiler import (
+    _backend,
+    _binding,
+    _published_rule,
+    _schema,
+)
+
+
+def _compile_worker_plan(
+    *,
+    input_schema,
+    output_schema,
+    steps,
+    lookup_context=None,
+):
+    from app.core.data_rules.compilers.polars import PolarsRuleCompiler
+
+    input_binding = _binding(input_schema, access_mode="read")
+    output_binding = _binding(output_schema, access_mode="write")
+    backend = _backend(
+        max_rows=1_000_000,
+        max_artifact_bytes=256 * 1024 * 1024,
+        memory_limit_bytes=16 * 1024 * 1024,
+        lookup_bindings=lookup_context or {},
+    )
+    rule = _published_rule(
+        input_schema,
+        output_schema,
+        steps,
+    )
+    return PolarsRuleCompiler().compile(
+        rule_version=rule,
+        input_schema=input_schema,
+        output_schema=output_schema,
+        input_binding=input_binding,
+        output_binding=output_binding,
+        backend=backend,
+    )["plan"]
+
+
+def _assert_worker_resource_failure(plan, tmp_path, *, lookup_paths=None):
+    from app.runner.polars_worker import (
+        PolarsWorkerResourceError,
+        execute_isolated_polars_plan,
+    )
+
+    with pytest.raises(
+        PolarsWorkerResourceError,
+        match="hard memory limit",
+    ):
+        execute_isolated_polars_plan(
+            {
+                "plan": plan,
+                "input_path": str(tmp_path / "input.parquet"),
+                "lookup_paths": lookup_paths or {},
+                "output_path": str(tmp_path / "output.parquet"),
+                "masking_policies": {},
+            },
+            memory_limit_bytes=plan["resource_limits"][
+                "memory_limit_bytes"
+            ],
+        )
+
+
+def test_isolated_polars_worker_runs_outside_runner_process():
+    from app.runner.polars_worker import run_isolated_memory_probe
+
+    result = run_isolated_memory_probe(
+        allocate_bytes=1_024,
+        memory_limit_bytes=16 * 1024 * 1024,
+    )
+
+    assert result["worker_pid"] != os.getpid()
+    assert result["allocated_bytes"] == 1_024
+
+
+def test_isolated_polars_worker_fails_deterministically_at_hard_memory_limit():
+    from app.runner.polars_worker import (
+        PolarsWorkerResourceError,
+        run_isolated_memory_probe,
+    )
+
+    with pytest.raises(
+        PolarsWorkerResourceError,
+        match="hard memory limit",
+    ):
+        run_isolated_memory_probe(
+            allocate_bytes=128 * 1024 * 1024,
+            memory_limit_bytes=8 * 1024 * 1024,
+        )
+
+
+def test_regex_peak_allocation_fails_inside_isolated_worker(tmp_path):
+    schema = _schema(
+        "bd:regex:raw",
+        [("text", "string", False)],
+    )
+    plan = _compile_worker_plan(
+        input_schema=schema,
+        output_schema={
+            **schema,
+            "id": _schema(
+                "bd:regex:clean",
+                [("text", "string", False)],
+            )["id"],
+            "schema_ref": "bd:regex:clean",
+        },
+        steps=[
+            {
+                "id": "expand",
+                "op": "regex_replace",
+                "column": "text",
+                "pattern": "a",
+                "replacement": "x" * 64,
+            }
+        ],
+    )
+    pl.DataFrame({"text": ["a" * 1_000_000]}).write_parquet(
+        tmp_path / "input.parquet"
+    )
+
+    _assert_worker_resource_failure(plan, tmp_path)
+
+
+def test_group_peak_allocation_fails_inside_isolated_worker(tmp_path):
+    input_schema = _schema(
+        "bd:group:raw",
+        [("group_key", "string", False), ("amount", "integer", False)],
+    )
+    output_schema = _schema(
+        "bd:group:clean",
+        [
+            ("group_key", "string", False),
+            ("total_amount", "integer", False),
+        ],
+    )
+    plan = _compile_worker_plan(
+        input_schema=input_schema,
+        output_schema=output_schema,
+        steps=[
+            {
+                "id": "sum_by_key",
+                "op": "aggregate",
+                "group_by": ["group_key"],
+                "aggregations": {
+                    "total_amount": {
+                        "function": "sum",
+                        "column": "amount",
+                    }
+                },
+            }
+        ],
+    )
+    row_count = 300_000
+    pl.DataFrame(
+        {
+            "group_key": [
+                f"group-{index:040d}" for index in range(row_count)
+            ],
+            "amount": [1] * row_count,
+        }
+    ).write_parquet(tmp_path / "input.parquet")
+
+    _assert_worker_resource_failure(plan, tmp_path)
+
+
+def test_join_peak_allocation_fails_inside_isolated_worker(tmp_path):
+    input_schema = _schema(
+        "bd:join:raw",
+        [("id", "integer", False)],
+    )
+    lookup_schema = _schema(
+        "bd:join:lookup",
+        [("lookup_id", "integer", False), ("label", "string", False)],
+    )
+    output_schema = _schema(
+        "bd:join:clean",
+        [("id", "integer", False), ("label", "string", False)],
+    )
+    lookup_binding = _binding(
+        lookup_schema,
+        access_mode="read",
+        object_ref="join-lookup",
+    )
+    plan = _compile_worker_plan(
+        input_schema=input_schema,
+        output_schema=output_schema,
+        steps=[
+            {
+                "id": "join_label",
+                "op": "lookup_join",
+                "lookup": {
+                    "binding_id": lookup_binding["id"],
+                    "left_on": ["id"],
+                    "right_on": ["lookup_id"],
+                    "select": {"label": "label"},
+                    "how": "left",
+                },
+            }
+        ],
+        lookup_context={
+            lookup_binding["id"]: {
+                "binding": lookup_binding,
+                "schema": lookup_schema,
+            }
+        },
+    )
+    row_count = 250_000
+    pl.DataFrame({"id": range(row_count)}).write_parquet(
+        tmp_path / "input.parquet"
+    )
+    lookup_path = tmp_path / "lookup.parquet"
+    pl.DataFrame(
+        {
+            "lookup_id": range(row_count),
+            "label": [f"label-{index:048d}" for index in range(row_count)],
+        }
+    ).write_parquet(lookup_path)
+
+    _assert_worker_resource_failure(
+        plan,
+        tmp_path,
+        lookup_paths={lookup_binding["id"]: str(lookup_path)},
+    )

+ 109 - 5
tests/runner/test_rule_polars.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import copy
 import copy
+from datetime import date, datetime
 
 
 import polars as pl
 import polars as pl
 import pytest
 import pytest
@@ -24,7 +25,7 @@ def _plan_store(client):
         bucket="dataops-rules",
         bucket="dataops-rules",
         max_artifact_bytes=8 * 1024 * 1024,
         max_artifact_bytes=8 * 1024 * 1024,
         max_rows=10_000,
         max_rows=10_000,
-        memory_limit_bytes=32 * 1024 * 1024,
+        memory_limit_bytes=256 * 1024 * 1024,
         max_ttl_seconds=3600,
         max_ttl_seconds=3600,
     )
     )
 
 
@@ -37,9 +38,9 @@ class Resolver:
         self.attest_error = None
         self.attest_error = None
         self.registrations = []
         self.registrations = []
 
 
-    def resolve(self, *, binding_id, correlation_id):
+    def resolve(self, *, binding_id, correlation_id, kind):
         self.calls.append((binding_id, correlation_id))
         self.calls.append((binding_id, correlation_id))
-        self.events.append(("resolve", binding_id))
+        self.events.append(("resolve", binding_id, kind))
         return self.values[binding_id]
         return self.values[binding_id]
 
 
     def attest_binding(self, *, binding_id, binding_hash, access_mode):
     def attest_binding(self, *, binding_id, binding_hash, access_mode):
@@ -67,6 +68,7 @@ class Resolver:
                 "binding_hash": binding_hash,
                 "binding_hash": binding_hash,
             }
             }
         )
         )
+        return artifact
 
 
 
 
 def _compiled_plan(steps):
 def _compiled_plan(steps):
@@ -88,7 +90,7 @@ def _compiled_plan(steps):
         output_schema=output_schema,
         output_schema=output_schema,
         input_binding=input_binding,
         input_binding=input_binding,
         output_binding=output_binding,
         output_binding=output_binding,
-        backend=_backend(),
+        backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
     )
     )
     return compiled, input_binding
     return compiled, input_binding
 
 
@@ -189,6 +191,7 @@ def test_polars_adapter_reconstructs_assert_and_deduplicate_and_writes_artifact(
         {"step_id": "mobile_format", "count": 1}
         {"step_id": "mobile_format", "count": 1}
     ]
     ]
     assert result["commit_outcome"] == "committed"
     assert result["commit_outcome"] == "committed"
+    assert "schema_fields" not in result
     assert resolver.events[0] == (
     assert resolver.events[0] == (
         "attest",
         "attest",
         compiled["plan"]["output_binding_id"],
         compiled["plan"]["output_binding_id"],
@@ -400,7 +403,7 @@ def test_polars_adapter_uses_exact_decimal_and_timestamptz_output_contracts():
         output_schema=output_schema,
         output_schema=output_schema,
         input_binding=source_binding,
         input_binding=source_binding,
         output_binding=output_binding,
         output_binding=output_binding,
-        backend=_backend(),
+        backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
     )
     )
     store = _plan_store(FakeMinio())
     store = _plan_store(FakeMinio())
     correlation_id = new_governance_uid()
     correlation_id = new_governance_uid()
@@ -448,6 +451,107 @@ def test_polars_adapter_uses_exact_decimal_and_timestamptz_output_contracts():
     )
     )
 
 
 
 
+def test_polars_expression_date_and_timestamp_use_plan_timezone():
+    from app.core.data_rules.compilers.polars import PolarsRuleCompiler
+    from app.runner.rule_polars import PolarsRulePlanAdapter
+
+    input_schema = _schema(
+        "bd:event:raw",
+        [
+            ("raw_time", "string", False),
+            ("cast_time", "string", False),
+        ],
+    )
+    output_schema = _schema(
+        "bd:event:clean",
+        [
+            ("raw_time", "string", False),
+            ("cast_time", "timestamp", False),
+            ("local_date", "date", False),
+            ("local_time", "timestamp", False),
+        ],
+    )
+    source_binding = _binding(input_schema, access_mode="read")
+    output_binding = _binding(output_schema, access_mode="write")
+    rule = _published_rule(
+        input_schema,
+        output_schema,
+        [
+            {
+                "id": "cast_timestamp",
+                "op": "cast",
+                "column": "cast_time",
+                "to": "timestamp",
+                "on_error": "fail",
+            },
+            {
+                "id": "derive_date",
+                "op": "derive",
+                "target": "local_date",
+                "expression": "date(raw_time)",
+            },
+            {
+                "id": "derive_timestamp",
+                "op": "derive",
+                "target": "local_time",
+                "expression": "timestamp(raw_time)",
+            },
+        ],
+    )
+    compiled = PolarsRuleCompiler().compile(
+        rule_version=rule,
+        input_schema=input_schema,
+        output_schema=output_schema,
+        input_binding=source_binding,
+        output_binding=output_binding,
+        backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
+    )
+    store = _plan_store(FakeMinio())
+    correlation_id = new_governance_uid()
+    source = store.write(
+        pl.DataFrame(
+            {
+                "raw_time": ["2026-07-22T16:30:00+00:00"],
+                "cast_time": ["2026-07-22T16:30:00+00:00"],
+            }
+        ),
+        correlation_id,
+        600,
+        schema_fields=compiled["plan"]["input_fields"],
+        limits=compiled["plan"]["resource_limits"],
+    )
+    resolver = Resolver(
+        {
+            source_binding["id"]: {
+                **source,
+                "binding_hash": compiled["plan"]["input_binding_hash"],
+            }
+        }
+    )
+
+    result = PolarsRulePlanAdapter(
+        artifact_store=store,
+        artifact_resolver=resolver,
+    ).execute(
+        plan=compiled["plan"],
+        node=_node(compiled),
+        parameters={},
+        write_authorized=True,
+        correlation_id=correlation_id,
+    )
+    output = store.read(
+        result["artifact_ref"],
+        result["digest"],
+        expected_schema_fields=compiled["plan"]["output_fields"],
+        limits=compiled["plan"]["resource_limits"],
+    ).collect()
+
+    assert output["local_date"].item() == date(2026, 7, 23)
+    assert output["local_time"].item() == datetime(2026, 7, 23, 0, 30)
+    assert output["cast_time"].item() == datetime(2026, 7, 23, 0, 30)
+    assert output.schema["local_time"] == pl.Datetime
+
+
 def test_rule_executor_attests_polars_canonical_hashes_and_forwards_correlation():
 def test_rule_executor_attests_polars_canonical_hashes_and_forwards_correlation():
     from app.runner.rules import RulePlanExecutor
     from app.runner.rules import RulePlanExecutor
 
 

+ 111 - 19
tests/test_data_rule_runtime_sbom.py

@@ -1,10 +1,65 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
+import hashlib
 import json
 import json
 from pathlib import Path
 from pathlib import Path
 
 
+from jsonschema import Draft7Validator
+from referencing import Registry, Resource
+
 ROOT = Path(__file__).resolve().parents[1]
 ROOT = Path(__file__).resolve().parents[1]
 SBOM = ROOT / "docs" / "security" / "data-rule-runtime-sbom.json"
 SBOM = ROOT / "docs" / "security" / "data-rule-runtime-sbom.json"
+SCHEMAS = ROOT / "docs" / "security" / "cyclonedx-1.5-schema"
+
+
+def _load(name: str):
+    return json.loads((SCHEMAS / name).read_text(encoding="utf-8"))
+
+
+def test_data_rule_runtime_sbom_validates_against_cyclonedx_15_schema():
+    expected_schema_digests = {
+        "bom-1.5.schema.json": (
+            "a00fcba23a44b72179ac1f288be4c3529"
+            "b59f6e1b3719709a40685f177516b46"
+        ),
+        "spdx.schema.json": (
+            "c87aa7bb5eb503d40b52ec6bf00de8045"
+            "df15da7a13cea48d290cf6d36a8d2ea"
+        ),
+        "jsf-0.82.schema.json": (
+            "2faf5eb3651f2ae5f46091a131770d8d8"
+            "47bbd121139d19c85fc7051bfa58c46"
+        ),
+    }
+    for name, expected in expected_schema_digests.items():
+        assert hashlib.sha256((SCHEMAS / name).read_bytes()).hexdigest() == (
+            expected
+        )
+    document = json.loads(SBOM.read_text(encoding="utf-8"))
+    schema = _load("bom-1.5.schema.json")
+    registry = Registry().with_resources(
+        [
+            (
+                "http://cyclonedx.org/schema/spdx.schema.json",
+                Resource.from_contents(_load("spdx.schema.json")),
+            ),
+            (
+                "http://cyclonedx.org/schema/jsf-0.82.schema.json",
+                Resource.from_contents(_load("jsf-0.82.schema.json")),
+            ),
+        ]
+    )
+
+    Draft7Validator.check_schema(schema)
+    errors = sorted(
+        Draft7Validator(
+            schema,
+            registry=registry,
+        ).iter_errors(document),
+        key=lambda error: list(error.absolute_path),
+    )
+
+    assert errors == []
 
 
 
 
 def test_data_rule_runtime_sbom_pins_official_oss_dependencies():
 def test_data_rule_runtime_sbom_pins_official_oss_dependencies():
@@ -16,24 +71,61 @@ def test_data_rule_runtime_sbom_pins_official_oss_dependencies():
 
 
     assert document["bomFormat"] == "CycloneDX"
     assert document["bomFormat"] == "CycloneDX"
     assert document["specVersion"] == "1.5"
     assert document["specVersion"] == "1.5"
-    assert components["polars"] == {
-        "type": "library",
-        "name": "polars",
-        "version": "1.42.1",
-        "purl": "pkg:pypi/polars@1.42.1",
-        "license": "MIT",
-        "officialSource": "https://github.com/pola-rs/polars",
-        "purpose": "Bounded lazy/dataframe execution for governed batch rules",
-    }
-    assert components["minio"] == {
-        "type": "library",
-        "name": "minio",
-        "version": "7.2.10",
-        "purl": "pkg:pypi/minio@7.2.10",
-        "license": "Apache-2.0",
-        "officialSource": "https://github.com/minio/minio-py",
-        "purpose": "Digest-bound Parquet artifact transport and metadata",
+    assert set(components) == {
+        "minio",
+        "polars",
+        "polars-runtime-32",
+        "psutil",
+        "pyarrow",
     }
     }
+    for name, version, license_id, source in (
+        (
+            "polars",
+            "1.42.1",
+            "MIT",
+            "https://github.com/pola-rs/polars",
+        ),
+        (
+            "polars-runtime-32",
+            "1.42.1",
+            "MIT",
+            "https://github.com/pola-rs/polars",
+        ),
+        (
+            "pyarrow",
+            "21.0.0",
+            "Apache-2.0",
+            "https://github.com/apache/arrow",
+        ),
+        (
+            "psutil",
+            "5.9.8",
+            "BSD-3-Clause",
+            "https://github.com/giampaolo/psutil",
+        ),
+        (
+            "minio",
+            "7.2.10",
+            "Apache-2.0",
+            "https://github.com/minio/minio-py",
+        ),
+    ):
+        component = components[name]
+        assert component["version"] == version
+        assert component["licenses"] == [
+            {"license": {"id": license_id}}
+        ]
+        assert component["externalReferences"] == [
+            {"type": "vcs", "url": source}
+        ]
+        assert component["properties"][0]["name"] == "dataops:purpose"
+        assert not {"license", "officialSource", "purpose"} & set(component)
+
     requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8")
     requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8")
-    assert "polars==1.42.1" in requirements
-    assert "minio==7.2.10" in requirements
+    for requirement in (
+        "polars==1.42.1",
+        "pyarrow==21.0.0",
+        "psutil==5.9.8",
+        "minio==7.2.10",
+    ):
+        assert requirement in requirements

+ 11 - 2
tests/test_data_rule_schema.py

@@ -148,9 +148,18 @@ 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)",
+        "UNIQUE (correlation_id, binding_id, artifact_kind)",
     ):
     ):
         assert expected in source
         assert expected 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 "pass" in downgrade
+    assert "raise RuntimeError" in downgrade
+    spec = importlib.util.spec_from_file_location(
+        "rule_run_artifact_catalog_migration",
+        ARTIFACT_CATALOG_MIGRATION,
+    )
+    assert spec is not None and spec.loader is not None
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    with pytest.raises(RuntimeError, match="forward-only|cannot downgrade"):
+        module.downgrade()

Някои файлове не бяха показани, защото твърде много файлове са промени