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

feat: gate rule publication on compile and tests

马小龙 4 недель назад
Родитель
Сommit
370fba0193

+ 193 - 0
.superpowers/sdd/task-7-report.md

@@ -0,0 +1,193 @@
+# Task 7 Report — Trusted rule generation and publication gates
+
+Date: 2026-07-24
+Branch: `codex/data-rule-execution-m3a-m5`
+
+## Outcome
+
+Task 7 establishes a fail-closed path from an AI-authored natural-language
+candidate to an immutable published RuleVersion. Neither caller-supplied
+compile evidence nor caller-supplied test evidence can advance lifecycle
+state.
+
+The delivered chain is:
+
+1. `/interpret` resolves a server-owned schema snapshot and optional
+   server-owned sample artifact before invoking the authoring model.
+2. A ready rule candidate is persisted with its actor, source hash, candidate
+   hash, RuleSpec hash, model hash, prompt hash, context hash, and all bounded
+   repair attempts.
+3. The server issues a short-lived HMAC-SHA256 generation receipt over that
+   closed claim set.
+4. RuleVersion creation locks the generation row, verifies every claim against
+   canonical database state, consumes the receipt exactly once, and creates a
+   `draft` RuleVersion plus an immutable validation profile.
+5. Logical validation compiles a closed, validation-only Polars plan against
+   the pinned schema snapshot.
+6. Logical testing executes that exact plan in the isolated Polars worker
+   against the pinned MinIO sample artifact. Digest, schema, resource, row,
+   expiry, and ownership checks are enforced by `ArtifactStore`.
+7. Successful evidence advances RuleVersion `draft -> validated` and logical
+   plan `compiled -> tested`.
+8. Publication requires exact successful compile and test evidence and advances
+   both objects to `published`.
+9. Deployment-bound physical plans independently pass
+   `compiled -> tested -> published`. They must reference a published
+   RuleVersion and its successful logical test evidence.
+
+The public catalog returns published rules only. DataFlow release continues to
+consume published plans only, so Task 7 does not weaken the Task 4–6 execution
+boundary.
+
+## Receipt and concurrency boundary
+
+`GenerationReceiptSigner` signs a compact, closed JSON claim set containing:
+
+- receipt version;
+- generation run ID;
+- actor UID;
+- source text hash;
+- candidate hash;
+- RuleSpec hash;
+- model hash;
+- prompt hash;
+- validation context hash;
+- issued-at and expiry timestamps.
+
+Draft creation performs `SELECT ... FOR UPDATE` on the generation record and a
+compare-and-set consumption update. A receipt is rejected if it is expired,
+tampered, belongs to another actor, has a different source/RuleSpec/candidate
+or validation context, has already been consumed, or is linked to another
+version.
+
+Publication uses row locks and state predicates. Same-actor retries after an
+unknown commit outcome return the canonical published result; a different
+actor cannot use that replay path.
+
+## AI repair boundary
+
+The authoring agent now has a deterministic maximum of two repair attempts.
+Each attempt persists:
+
+- attempt number and outcome;
+- candidate hash;
+- deterministic error code;
+- model, prompt, and context hashes.
+
+Only malformed JSON and closed candidate-contract errors are repairable.
+Ambiguity and low confidence return clarification requirements immediately.
+Permission failures, destructive scope, lifecycle failures, and execution
+failures are never sent into the automatic repair loop.
+
+## Logical and physical evidence
+
+Logical compile evidence binds the RuleVersion, compiler version, exact plan
+hash, schema hashes, capabilities, and actor. Logical test evidence additionally
+binds the exact trusted run ID and server-generated attestation:
+
+- input artifact digest;
+- output artifact digest;
+- output schema hash;
+- violation digest;
+- bounded row and violation counts.
+
+The logical runner supports `assert` with both `reject` and `quarantine`.
+Quarantined rows are removed from the primary output, counted separately as
+`rows_quarantined`, and are not misreported as rejected. The compiler version
+was advanced to `dataops-polars-1.43.0` for this result-contract change.
+
+Physical SQL preflight validates the exact SQLGlot-bound plan and executes only
+`EXPLAIN <compiled INSERT ... SELECT ...>` through the governed data-source
+manager. It never uses `ANALYZE` and never executes the DML. The resulting
+evidence contains dialect, statement digest, and EXPLAIN digest. Real
+PostgreSQL and MySQL tests assert that destination row counts remain zero.
+
+Physical Polars preflight stages the latest trusted, ready, unexpired input
+artifact and executes the exact bound plan in the isolated worker. Both
+physical backends are rejected on current schema, binding, dialect, plan-hash,
+RuleVersion, or logical-evidence drift.
+
+## Forward-only migration
+
+Migration `20260723_200` is forward-only and adds:
+
+- corrected RuleVersion and execution-plan lifecycle constraints;
+- receipt hash, consumption timestamp, validation context, model hash, and
+  prompt hash on generation runs;
+- one-time receipt/version uniqueness constraints;
+- persisted generation attempts;
+- immutable validation profiles;
+- logical plans and logical compile/test evidence;
+- physical compile/test evidence binding fields;
+- validation failures and publication audits;
+- the published-rule catalog index.
+
+The migration preflights incompatible legacy lifecycle values before replacing
+constraints. Downgrade is deliberately rejected because deleting publication
+and generation evidence would break audit and replay guarantees.
+
+## API surface
+
+The following server-owned operations are available:
+
+- `POST /api/rules/interpret`;
+- `POST /api/rules/rule-versions`;
+- `POST /api/rules/rule-versions/<id>/validate`;
+- `POST /api/rules/rule-versions/<id>/test`;
+- `POST /api/rules/rule-versions/<id>/publish`;
+- `GET /api/rules/rule-versions/<id>/evidence`;
+- `GET /api/rules/catalog`;
+- `POST /api/rules/execution-plans/<id>/validate`;
+- `POST /api/rules/execution-plans/<id>/test`;
+- `POST /api/rules/execution-plans/<id>/publish`.
+
+Request bodies use closed shapes. Compile/test endpoints do not accept caller
+evidence. Editor permissions cover validation/testing while publication keeps
+the existing administrative permission boundary.
+
+## Acceptance evidence
+
+Fail-first evidence included:
+
+- publication rejected without exact compile and test evidence;
+- invalid, expired, replayed, actor-mismatched, source-mismatched, and
+  RuleSpec-mismatched receipts;
+- concurrent receipt consumption;
+- caller-forged test evidence;
+- schema, binding, dialect, and plan drift;
+- bounded repair exhaustion and no-repair ambiguity;
+- initial quarantine compilation failure before support was added.
+
+Final verification:
+
+- focused Task 7/compiler/API suite: `92 passed`;
+- expanded data-rule and Runner regression: `284 passed`;
+- real PostgreSQL + MinIO receipt/logical/physical-Polars lifecycle:
+  `1 passed`;
+- real PostgreSQL and MySQL SQL EXPLAIN preflight with zero destination writes:
+  `2 passed`;
+- combined real lifecycle and SQL integration files: `6 passed`;
+- full repository suite:
+  `616 passed, 29 skipped, 59 subtests passed`;
+- changed-file Ruff: `All checks passed!`;
+- `git diff --check`: passed;
+- local PostgreSQL Alembic current/upgrade:
+  `20260723_200 (head)`.
+
+Skipped tests are environment-gated integration suites. The required real
+PostgreSQL, MySQL, and MinIO acceptance tests above were run explicitly against
+the local Docker services and passed.
+
+## Residual scope
+
+Task 7 records publication preflight run IDs and exact evidence in dedicated
+logical/physical evidence tables. It does not fabricate production `rule_runs`
+for logical validation because those rows require a real deployment and
+component binding. Production execution evidence remains owned by the Task 6
+Runner path after a physical plan is published.
+
+Logical validation currently pins one schema snapshot for both input and output.
+Rules that intentionally change their output schema require a future validation
+profile extension with distinct server-owned input and output snapshots. This
+does not affect same-schema cleaning rules, and the current implementation
+fails closed rather than guessing an output schema.

+ 290 - 8
app/api/data_rules/routes.py

@@ -2,6 +2,9 @@
 
 from __future__ import annotations
 
+import hashlib
+import json
+from datetime import UTC, datetime, timedelta
 from typing import Any
 
 from flask import current_app, g, jsonify, request
@@ -21,6 +24,16 @@ from app.core.data_rules.contracts import (
     validate_standard_spec,
 )
 from app.core.data_rules.production_line import resolve_production_line
+from app.core.data_rules.publication import (
+    GenerationReceiptSigner,
+    LogicalRuleCompiler,
+    PhysicalPlanPublicationService,
+    RulePublicationService,
+    RuleValidationRejected,
+    ServerOwnedLogicalDryRunRunner,
+    ServerOwnedPhysicalPreflightRunner,
+    generation_receipt_claims,
+)
 from app.core.data_rules.release import ProductionLineReleaseService
 from app.core.data_rules.repository import DataRuleRepository
 from app.core.data_rules.schema_resolver import (
@@ -73,6 +86,103 @@ def _release_service() -> ProductionLineReleaseService:
     return ProductionLineReleaseService(repository, schema_resolver=resolver)
 
 
+def _receipt_signer() -> GenerationReceiptSigner:
+    configured = current_app.extensions.get("generation_receipt_signer")
+    if configured is not None:
+        return configured
+    signer = GenerationReceiptSigner(current_app.config["SECRET_KEY"])
+    current_app.extensions["generation_receipt_signer"] = signer
+    return signer
+
+
+def _rule_artifact_store():
+    configured = current_app.extensions.get("rule_artifact_store")
+    if configured is not None:
+        return configured
+    from minio import Minio
+
+    from app.runner.artifacts import ArtifactStore
+
+    store = ArtifactStore(
+        Minio(
+            current_app.config["MINIO_HOST"],
+            access_key=current_app.config["MINIO_USER"],
+            secret_key=current_app.config["MINIO_PASSWORD"],
+            secure=bool(current_app.config["MINIO_SECURE"]),
+        ),
+        bucket=current_app.config["MINIO_BUCKET"],
+        max_artifact_bytes=32 * 1024 * 1024,
+        max_rows=100_000,
+        memory_limit_bytes=256 * 1024 * 1024,
+        max_ttl_seconds=3600,
+    )
+    current_app.extensions["rule_artifact_store"] = store
+    return store
+
+
+def _publication_service() -> RulePublicationService:
+    configured = current_app.extensions.get("rule_publication_service")
+    if configured is not None:
+        return configured
+    test_runner = current_app.extensions.get("rule_validation_test_runner")
+    if test_runner is None:
+        try:
+            test_runner = ServerOwnedLogicalDryRunRunner(
+                _rule_artifact_store()
+            )
+        except Exception as exc:
+            raise RuntimeError(
+                "trusted rule validation test runner is not configured"
+            ) from exc
+        current_app.extensions["rule_validation_test_runner"] = test_runner
+    service = RulePublicationService(
+        _repository(),
+        receipt_signer=_receipt_signer(),
+        compiler=LogicalRuleCompiler(),
+        test_runner=test_runner,
+    )
+    current_app.extensions["rule_publication_service"] = service
+    return service
+
+
+def _physical_publication_service() -> PhysicalPlanPublicationService:
+    configured = current_app.extensions.get(
+        "physical_plan_publication_service"
+    )
+    if configured is not None:
+        return configured
+    test_runner = current_app.extensions.get("rule_physical_test_runner")
+    if test_runner is None:
+        try:
+            from app.core.data_source.runtime import get_data_source_manager
+
+            test_runner = ServerOwnedPhysicalPreflightRunner(
+                _rule_artifact_store(),
+                datasource_manager=get_data_source_manager(),
+            )
+        except Exception as exc:
+            raise RuntimeError(
+                "trusted physical plan test runner is not configured"
+            ) from exc
+        current_app.extensions["rule_physical_test_runner"] = test_runner
+    service = PhysicalPlanPublicationService(
+        _repository(), test_runner=test_runner
+    )
+    current_app.extensions["physical_plan_publication_service"] = service
+    return service
+
+
+def _metadata_hash(value: Any) -> str:
+    return hashlib.sha256(
+        json.dumps(
+            value,
+            sort_keys=True,
+            separators=(",", ":"),
+            ensure_ascii=False,
+        ).encode("utf-8")
+    ).hexdigest()
+
+
 @bp.get("/capabilities")
 def capabilities():
     return jsonify(
@@ -130,18 +240,53 @@ def _authoring_agent() -> RuleAuthoringAgent:
 def interpret_rule():
     try:
         body = _body()
+        repository = _repository()
+        validation_context = repository.resolve_validation_context(
+            body.get("context", {})
+        )
         result = _authoring_agent().interpret(
             source_text=body.get("source_text"),
             authoring_surface=body.get("authoring_surface"),
-            context=body.get("context", {}),
+            context=validation_context,
+        )
+        audit = repository.record_generation_run(
+            evidence=result,
+            created_by=g.current_user["id"],
+            validation_context=validation_context,
         )
-        audit = _repository().record_generation_run(evidence=result)
-        db.session.commit()
         result = {
             **result,
             "generation_run_id": audit["id"],
             "correlation_id": audit["correlation_id"],
         }
+        candidate = result.get("candidate")
+        if (
+            result.get("status") == "ready"
+            and isinstance(candidate, dict)
+            and candidate.get("candidate_type") == "rule"
+            and isinstance(candidate.get("rule_spec"), dict)
+        ):
+            claims = generation_receipt_claims(
+                generation_run_id=audit["id"],
+                actor_uid=g.current_user["id"],
+                source_text=result["source_text"],
+                candidate_hash=result["candidate_hash"],
+                rule_spec=candidate["rule_spec"],
+                model_hash=result.get("model_hash")
+                or _metadata_hash(
+                    {
+                        "provider": result.get("model_provider", "unknown"),
+                        "name": result.get("model_name", "unknown"),
+                    }
+                ),
+                prompt_hash=result.get("prompt_hash")
+                or _metadata_hash(result.get("prompt_version", "unknown")),
+                context_hash=result["context_hash"],
+                expires_at=datetime.now(UTC)
+                + timedelta(minutes=10),
+            )
+            result["generation_receipt"] = _receipt_signer().issue(claims)
+        db.session.commit()
         return jsonify(success(result))
     except (TypeError, ValueError):
         db.session.rollback()
@@ -162,6 +307,7 @@ def create_rule_version():
             {
                 "rule_spec",
                 "source_text",
+                "generation_receipt",
                 "category",
                 "source_language",
                 "generated_kind",
@@ -170,13 +316,14 @@ def create_rule_version():
         # Enforce V2 at the HTTP boundary even when a test/different
         # repository implementation is injected.
         rule_spec = validate_rule_spec(body.get("rule_spec"))
-        result = _repository().create_rule_version(
+        result = _publication_service().create_draft(
             rule_spec=rule_spec,
             source_text=body.get("source_text"),
             category=body.get("category", "general"),
             source_language=body.get("source_language", "zh-CN"),
             generated_kind=body.get("generated_kind", "rulespec"),
-            created_by=g.current_user["id"],
+            actor_uid=g.current_user["id"],
+            generation_receipt=body.get("generation_receipt"),
         )
         db.session.commit()
         return jsonify(success(result, "规则版本创建成功")), 201
@@ -197,9 +344,8 @@ def publish_rule_version(version_id: str):
             {},
         ):
             raise ValueError("publish request must not contain fields")
-        result = _repository().publish_rule_version(
-            version_id=version_id,
-            published_by=g.current_user["id"],
+        result = _publication_service().publish(
+            version_id, g.current_user["id"]
         )
         db.session.commit()
         return jsonify(success(result, "规则版本发布成功"))
@@ -212,6 +358,142 @@ def publish_rule_version(version_id: str):
         return jsonify(failed("规则版本发布失败", code=500)), 500
 
 
+@bp.post("/rule-versions/<version_id>/validate")
+def validate_rule_version(version_id: str):
+    try:
+        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+            None,
+            {},
+        ):
+            raise ValueError("validate request must not contain fields")
+        result = _publication_service().validate(
+            version_id, g.current_user["id"]
+        )
+        db.session.commit()
+        return jsonify(success(result, "规则版本编译验证成功"))
+    except RuleValidationRejected:
+        db.session.commit()
+        return jsonify(failed("规则版本编译验证失败", code=409)), 409
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("规则版本编译验证失败", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("规则验证服务未配置", code=503)), 503
+
+
+@bp.post("/rule-versions/<version_id>/test")
+def test_rule_version(version_id: str):
+    try:
+        body = _closed_body({"plan_id"})
+        result = _publication_service().test(
+            version_id,
+            g.current_user["id"],
+            plan_id=body.get("plan_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "规则版本样本测试成功"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("规则版本样本测试失败", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("规则测试服务未配置", code=503)), 503
+
+
+@bp.get("/rule-versions/<version_id>/evidence")
+def rule_version_evidence(version_id: str):
+    try:
+        return jsonify(success(_publication_service().evidence(version_id)))
+    except (TypeError, ValueError):
+        return jsonify(failed("规则证据不存在", code=404)), 404
+    except RuntimeError:
+        return jsonify(failed("规则验证服务未配置", code=503)), 503
+
+
+@bp.get("/catalog/rule-versions")
+def published_rule_catalog():
+    try:
+        query = request.args.get("query", "")
+        limit = int(request.args.get("limit", "50"))
+        return jsonify(
+            success(
+                {
+                    "items": _publication_service().catalog(
+                        query=query, limit=limit
+                    )
+                }
+            )
+        )
+    except (TypeError, ValueError):
+        return _bad_request("规则目录查询无效")
+    except RuntimeError:
+        return jsonify(failed("规则目录服务未配置", code=503)), 503
+
+
+@bp.post("/execution-plans/<plan_id>/validate")
+def validate_physical_plan(plan_id: str):
+    try:
+        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+            None,
+            {},
+        ):
+            raise ValueError("validate request must not contain fields")
+        result = _physical_publication_service().validate(
+            plan_id, g.current_user["id"]
+        )
+        db.session.commit()
+        return jsonify(success(result, "物理执行计划编译证据已确认"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("物理执行计划验证失败", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("物理计划验证服务未配置", code=503)), 503
+
+
+@bp.post("/execution-plans/<plan_id>/test")
+def test_physical_plan(plan_id: str):
+    try:
+        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+            None,
+            {},
+        ):
+            raise ValueError("test request must not contain fields")
+        result = _physical_publication_service().test(
+            plan_id, g.current_user["id"]
+        )
+        db.session.commit()
+        return jsonify(success(result, "物理执行计划样本测试成功"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("物理执行计划样本测试失败", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("物理计划测试服务未配置", code=503)), 503
+
+
+@bp.post("/execution-plans/<plan_id>/publish")
+def publish_physical_plan(plan_id: str):
+    try:
+        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+            None,
+            {},
+        ):
+            raise ValueError("publish request must not contain fields")
+        result = _physical_publication_service().publish(
+            plan_id, g.current_user["id"]
+        )
+        db.session.commit()
+        return jsonify(success(result, "物理执行计划发布成功"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("物理执行计划无法发布", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("物理计划发布服务未配置", code=503)), 503
+
+
 @bp.post("/standard-versions")
 def create_standard_version():
     try:

+ 102 - 15
app/core/data_rules/authoring.py

@@ -13,7 +13,6 @@ from app.core.data_rules.contracts import (
 )
 from app.core.llm.deepseek_client import create_llm_client, get_llm_model
 
-
 PROMPT_VERSION = "data-rule-authoring-v1"
 AUTHORING_SURFACES = {"data_standard", "data_flow"}
 SECRET_KEY_NAMES = {
@@ -158,6 +157,7 @@ class RuleAuthoringAgent:
         model,
         timeout_seconds=30,
         confidence_threshold=0.85,
+        max_repair_attempts=2,
     ):
         if (
             isinstance(timeout_seconds, bool)
@@ -173,6 +173,14 @@ class RuleAuthoringAgent:
         self.model = model
         self.timeout_seconds = timeout_seconds
         self.confidence_threshold = float(confidence_threshold)
+        if (
+            isinstance(max_repair_attempts, bool)
+            or not isinstance(max_repair_attempts, int)
+            or max_repair_attempts < 0
+            or max_repair_attempts > 2
+        ):
+            raise ValueError("max repair attempts must be between 0 and 2")
+        self.max_repair_attempts = max_repair_attempts
 
     def interpret(
         self,
@@ -194,23 +202,98 @@ class RuleAuthoringAgent:
         if len(encoded_context.encode("utf-8")) > 65536:
             raise ValueError("authoring context exceeds 65536 bytes")
 
-        raw = self.model.generate(
-            messages=build_rule_messages(source, authoring_surface, context),
-            response_schema=RULE_CANDIDATE_SCHEMA,
-            timeout_seconds=self.timeout_seconds,
-        )
-        if not isinstance(raw, str):
-            raise ValueError("rule authoring model must return JSON text")
-        try:
-            decoded = json.loads(raw)
-        except json.JSONDecodeError as exc:
-            raise ValueError(
-                "rule authoring model must return valid JSON"
-            ) from exc
-        candidate = validate_rule_candidate(decoded)
+        messages = build_rule_messages(source, authoring_surface, context)
+        prompt_hash = _hash(messages)
+        attempts = []
+        candidate = None
+        last_error = None
+        for attempt_number in range(self.max_repair_attempts + 1):
+            raw = self.model.generate(
+                messages=messages,
+                response_schema=RULE_CANDIDATE_SCHEMA,
+                timeout_seconds=self.timeout_seconds,
+            )
+            if not isinstance(raw, str):
+                raise ValueError("rule authoring model must return JSON text")
+            raw_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
+            try:
+                decoded = json.loads(raw)
+                candidate = validate_rule_candidate(decoded)
+            except (json.JSONDecodeError, ValueError) as exc:
+                last_error = exc
+                attempts.append(
+                    {
+                        "attempt": attempt_number,
+                        "status": "invalid",
+                        "candidate_hash": raw_hash,
+                        "error_code": "deterministic_contract_error",
+                        "model_hash": _hash(
+                            {
+                                "provider": getattr(
+                                    self.model, "provider", "unknown"
+                                ),
+                                "name": getattr(
+                                    self.model, "model_name", "unknown"
+                                ),
+                            }
+                        ),
+                        "prompt_hash": prompt_hash,
+                        "context_hash": _hash(context),
+                    }
+                )
+                if attempt_number >= self.max_repair_attempts:
+                    if isinstance(exc, json.JSONDecodeError):
+                        raise ValueError(
+                            "rule authoring model must return valid JSON"
+                        ) from exc
+                    raise
+                messages = [
+                    *messages,
+                    {
+                        "role": "user",
+                        "content": (
+                            "DETERMINISTIC_VALIDATION_ERROR: "
+                            "the previous JSON did not satisfy the closed "
+                            "RuleCandidate/RuleSpec contract. Return one "
+                            "complete corrected JSON object only. Do not "
+                            "change permissions, schema references, or "
+                            "resolve ambiguity by guessing."
+                        ),
+                    },
+                ]
+                continue
+            attempts.append(
+                {
+                    "attempt": attempt_number,
+                    "status": "valid",
+                    "candidate_hash": _hash(candidate),
+                    "error_code": None,
+                    "model_hash": _hash(
+                        {
+                            "provider": getattr(
+                                self.model, "provider", "unknown"
+                            ),
+                            "name": getattr(
+                                self.model, "model_name", "unknown"
+                            ),
+                        }
+                    ),
+                    "prompt_hash": prompt_hash,
+                    "context_hash": _hash(context),
+                }
+            )
+            break
+        if candidate is None:
+            raise ValueError("rule authoring model candidate is invalid") from last_error
         requires_clarification = bool(candidate["ambiguities"]) or (
             candidate["confidence"] < self.confidence_threshold
         )
+        model_hash = _hash(
+            {
+                "provider": getattr(self.model, "provider", "unknown"),
+                "name": getattr(self.model, "model_name", "unknown"),
+            }
+        )
         return {
             "status": (
                 "clarification_required" if requires_clarification else "ready"
@@ -232,4 +315,8 @@ class RuleAuthoringAgent:
             "schema_version": "1.0",
             "context_hash": _hash(context),
             "candidate_hash": _hash(candidate),
+            "model_hash": model_hash,
+            "prompt_hash": prompt_hash,
+            "repair_attempts": len(attempts) - 1,
+            "generation_attempts": attempts,
         }

+ 9 - 5
app/core/data_rules/compilers/polars.py

@@ -24,12 +24,13 @@ from app.core.data_rules.expressions import (
     validate_rule_expressions,
 )
 
-COMPILER_VERSION = "dataops-polars-1.42.1"
+COMPILER_VERSION = "dataops-polars-1.43.0"
 PLAN_SCHEMA_VERSION = "1.0"
 RESULT_CONTRACT = {
     "rows_in": "counted",
     "rows_out": "counted",
     "rows_rejected": "counted",
+    "rows_quarantined": "counted",
     "rows_filtered": "counted",
     "rows_deduplicated": "counted",
     "rows_join_dropped": "counted",
@@ -407,7 +408,7 @@ def _operation(value: Any) -> dict[str, Any]:
     ):
         raise ValueError("expression is unsupported by Polars")
     if operation == "assert" and (
-        result["on_failure"] != "reject"
+        result["on_failure"] not in {"reject", "quarantine"}
         or result["severity"]
         not in {
             "info",
@@ -788,13 +789,16 @@ class PolarsRuleCompiler(RuleCompiler):
                     "expression_ast": copy.deepcopy(step["expression_ast"]),
                 }
                 if op == "assert":
-                    if step.get("on_failure") != "reject":
+                    if step.get("on_failure") not in {
+                        "reject",
+                        "quarantine",
+                    }:
                         raise ValueError(
-                            "Polars assert currently supports reject only"
+                            "Polars assert failure action is unsupported"
                         )
                     operation.update(
                         {
-                            "on_failure": "reject",
+                            "on_failure": step["on_failure"],
                             "severity": step.get("severity", "error"),
                             "step_id": step["id"],
                         }

+ 845 - 0
app/core/data_rules/publication.py

@@ -0,0 +1,845 @@
+"""Trusted generation receipts and rule publication lifecycle gates."""
+
+from __future__ import annotations
+
+import base64
+import copy
+import hashlib
+import hmac
+import json
+import os
+import re
+import tempfile
+from contextlib import suppress
+from datetime import UTC, datetime
+from typing import Any
+
+from app.core.common.identifiers import ensure_governance_uid, new_governance_uid
+from app.core.data_rules.contracts import rule_spec_hash, validate_rule_spec
+
+_DIGEST = re.compile(r"^[0-9a-f]{64}$")
+_RECEIPT_KEYS = {
+    "version",
+    "generation_run_id",
+    "actor_uid",
+    "source_text_hash",
+    "candidate_hash",
+    "rule_spec_hash",
+    "model_hash",
+    "prompt_hash",
+    "context_hash",
+    "expires_at",
+}
+_COMPILED_KEYS = {
+    "backend",
+    "compiler_version",
+    "plan",
+    "plan_hash",
+    "schema_hashes",
+    "binding_hashes",
+    "capabilities",
+}
+_TEST_KEYS = {
+    "status",
+    "test_kind",
+    "run_id",
+    "plan_hash",
+    "schema_hashes",
+    "binding_hashes",
+    "counts",
+    "attestation",
+}
+
+
+class RuleValidationRejected(ValueError):
+    """A deterministic validation failure whose audit row must be committed."""
+
+
+def _canonical(value: Any) -> bytes:
+    try:
+        encoded = json.dumps(
+            value,
+            sort_keys=True,
+            separators=(",", ":"),
+            ensure_ascii=False,
+        )
+    except (TypeError, ValueError) as exc:
+        raise ValueError("publication value must be JSON serializable") from exc
+    return encoded.encode("utf-8")
+
+
+def _digest(value: Any, label: str) -> str:
+    normalized = str(value or "")
+    if not _DIGEST.fullmatch(normalized):
+        raise ValueError(f"{label} must be a sha256 digest")
+    return normalized
+
+
+def _uid(value: Any, label: str) -> str:
+    try:
+        return ensure_governance_uid({"uid": str(value)})
+    except ValueError as exc:
+        raise ValueError(f"{label} must be a valid UUIDv7") from exc
+
+
+def _hash_text(value: Any, label: str, maximum: int = 20_000) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    normalized = value.strip()
+    if len(normalized) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
+
+
+def _b64encode(value: bytes) -> str:
+    return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
+
+
+def _b64decode(value: str) -> bytes:
+    if (
+        not isinstance(value, str)
+        or not value
+        or re.fullmatch(r"[A-Za-z0-9_-]+", value) is None
+    ):
+        raise ValueError("generation receipt encoding is invalid")
+    try:
+        decoded = base64.urlsafe_b64decode(
+            value + ("=" * (-len(value) % 4))
+        )
+    except Exception as exc:
+        raise ValueError("generation receipt encoding is invalid") from exc
+    if _b64encode(decoded) != value:
+        raise ValueError("generation receipt encoding is not canonical")
+    return decoded
+
+
+def generation_receipt_claims(
+    *,
+    generation_run_id: str,
+    actor_uid: str,
+    source_text: str,
+    candidate_hash: str,
+    rule_spec: dict[str, Any],
+    model_hash: str,
+    prompt_hash: str,
+    context_hash: str,
+    expires_at: datetime,
+) -> dict[str, Any]:
+    """Build the closed, non-secret claim set signed by ``/interpret``."""
+
+    if not isinstance(expires_at, datetime) or expires_at.tzinfo is None:
+        raise ValueError("generation receipt expiry must be timezone aware")
+    spec = validate_rule_spec(rule_spec)
+    return {
+        "version": "1",
+        "generation_run_id": _uid(
+            generation_run_id, "generation_run_id"
+        ),
+        "actor_uid": _uid(actor_uid, "actor_uid"),
+        "source_text_hash": _hash_text(source_text, "source_text"),
+        "candidate_hash": _digest(candidate_hash, "candidate_hash"),
+        "rule_spec_hash": rule_spec_hash(spec),
+        "model_hash": _digest(model_hash, "model_hash"),
+        "prompt_hash": _digest(prompt_hash, "prompt_hash"),
+        "context_hash": _digest(context_hash, "context_hash"),
+        "expires_at": int(expires_at.timestamp()),
+    }
+
+
+class GenerationReceiptSigner:
+    """Issue and verify compact HMAC receipts without exposing the secret."""
+
+    def __init__(self, secret: str | bytes):
+        secret_bytes = (
+            secret.encode("utf-8") if isinstance(secret, str) else bytes(secret)
+        )
+        if len(secret_bytes) < 16:
+            raise ValueError("generation receipt secret is too short")
+        self._secret = secret_bytes
+
+    def issue(self, claims: dict[str, Any]) -> str:
+        normalized = self._validate_claims(claims)
+        payload = _b64encode(_canonical(normalized))
+        signature = hmac.new(
+            self._secret, payload.encode("ascii"), hashlib.sha256
+        ).digest()
+        return f"{payload}.{_b64encode(signature)}"
+
+    def verify(
+        self,
+        receipt: str,
+        *,
+        actor_uid: str,
+        source_text: str,
+        rule_spec: dict[str, Any],
+        now: datetime | None = None,
+    ) -> dict[str, Any]:
+        if not isinstance(receipt, str) or len(receipt) > 4096:
+            raise ValueError("generation receipt is invalid")
+        try:
+            payload, encoded_signature = receipt.split(".", 1)
+        except ValueError as exc:
+            raise ValueError("generation receipt is invalid") from exc
+        expected = hmac.new(
+            self._secret, payload.encode("ascii"), hashlib.sha256
+        ).digest()
+        provided = _b64decode(encoded_signature)
+        if not hmac.compare_digest(expected, provided):
+            raise ValueError("generation receipt signature is invalid")
+        try:
+            decoded = json.loads(_b64decode(payload))
+        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+            raise ValueError("generation receipt payload is invalid") from exc
+        claims = self._validate_claims(decoded)
+        current = now or datetime.now(UTC)
+        if current.tzinfo is None:
+            raise ValueError("generation receipt clock must be timezone aware")
+        if int(current.timestamp()) >= claims["expires_at"]:
+            raise ValueError("generation receipt has expired")
+        if claims["actor_uid"] != _uid(actor_uid, "actor_uid"):
+            raise ValueError("generation receipt actor does not match")
+        if claims["source_text_hash"] != _hash_text(
+            source_text, "source_text"
+        ):
+            raise ValueError("generation receipt source does not match")
+        if claims["rule_spec_hash"] != rule_spec_hash(
+            validate_rule_spec(rule_spec)
+        ):
+            raise ValueError("generation receipt RuleSpec does not match")
+        return claims
+
+    @staticmethod
+    def _validate_claims(value: Any) -> dict[str, Any]:
+        if not isinstance(value, dict) or set(value) != _RECEIPT_KEYS:
+            raise ValueError("generation receipt claims are invalid")
+        if value.get("version") != "1":
+            raise ValueError("generation receipt version is unsupported")
+        expires_at = value.get("expires_at")
+        if (
+            isinstance(expires_at, bool)
+            or not isinstance(expires_at, int)
+            or expires_at <= 0
+        ):
+            raise ValueError("generation receipt expiry is invalid")
+        return {
+            "version": "1",
+            "generation_run_id": _uid(
+                value.get("generation_run_id"), "generation_run_id"
+            ),
+            "actor_uid": _uid(value.get("actor_uid"), "actor_uid"),
+            "source_text_hash": _digest(
+                value.get("source_text_hash"), "source_text_hash"
+            ),
+            "candidate_hash": _digest(
+                value.get("candidate_hash"), "candidate_hash"
+            ),
+            "rule_spec_hash": _digest(
+                value.get("rule_spec_hash"), "rule_spec_hash"
+            ),
+            "model_hash": _digest(value.get("model_hash"), "model_hash"),
+            "prompt_hash": _digest(value.get("prompt_hash"), "prompt_hash"),
+            "context_hash": _digest(
+                value.get("context_hash"), "context_hash"
+            ),
+            "expires_at": expires_at,
+        }
+
+
+def _normalize_compiled(value: Any) -> dict[str, Any]:
+    if not isinstance(value, dict) or set(value) != _COMPILED_KEYS:
+        raise ValueError("trusted compiler returned an invalid result")
+    if value.get("backend") not in {"sql_pushdown", "polars_batch"}:
+        raise ValueError("trusted compiler returned an unsupported backend")
+    compiler_version = str(value.get("compiler_version") or "").strip()
+    if not compiler_version or len(compiler_version) > 80:
+        raise ValueError("trusted compiler version is invalid")
+    if not isinstance(value.get("plan"), dict):
+        raise ValueError("trusted compiler plan is invalid")
+    for key in ("schema_hashes", "binding_hashes", "capabilities"):
+        if not isinstance(value.get(key), dict):
+            raise ValueError(f"trusted compiler {key} are invalid")
+    return {
+        **copy.deepcopy(value),
+        "compiler_version": compiler_version,
+        "plan_hash": _digest(value.get("plan_hash"), "plan_hash"),
+    }
+
+
+def _normalize_test(value: Any, context: dict[str, Any]) -> dict[str, Any]:
+    if not isinstance(value, dict) or set(value) != _TEST_KEYS:
+        raise ValueError("trusted test runner returned an invalid result")
+    if value.get("status") != "success":
+        raise ValueError("trusted rule dry-run did not succeed")
+    if value.get("test_kind") not in {"dry_run", "sample", "golden"}:
+        raise ValueError("trusted rule test kind is invalid")
+    _uid(value.get("run_id"), "test run_id")
+    if (
+        value.get("plan_hash") != context.get("plan_hash")
+        or value.get("schema_hashes") != context.get("schema_hashes")
+        or value.get("binding_hashes") != context.get("binding_hashes")
+    ):
+        raise ValueError("test evidence does not match the exact compiled plan")
+    counts = value.get("counts")
+    if not isinstance(counts, dict) or any(
+        isinstance(item, bool) or not isinstance(item, int) or item < 0
+        for item in counts.values()
+    ):
+        raise ValueError("trusted test counts are invalid")
+    attestation = value.get("attestation")
+    if not isinstance(attestation, dict):
+        raise ValueError("trusted test attestation is invalid")
+    return copy.deepcopy(value)
+
+
+class RulePublicationService:
+    """Coordinate only server-generated compile and test evidence."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        receipt_signer: GenerationReceiptSigner | None,
+        compiler,
+        test_runner,
+    ):
+        self.repository = repository
+        self.receipt_signer = receipt_signer
+        self.compiler = compiler
+        self.test_runner = test_runner
+
+    def create_draft(
+        self,
+        *,
+        rule_spec: dict[str, Any],
+        source_text: str,
+        actor_uid: str,
+        generation_receipt: str,
+        category: str,
+        source_language: str,
+        generated_kind: str,
+    ) -> dict[str, Any]:
+        if self.receipt_signer is None:
+            raise RuntimeError("generation receipt signer is not configured")
+        spec = validate_rule_spec(rule_spec)
+        actor = _uid(actor_uid, "actor_uid")
+        claims = self.receipt_signer.verify(
+            generation_receipt,
+            actor_uid=actor,
+            source_text=source_text,
+            rule_spec=spec,
+        )
+        receipt_hash = hashlib.sha256(
+            generation_receipt.encode("utf-8")
+        ).hexdigest()
+        return self.repository.create_draft_from_generation(
+            rule_spec=spec,
+            source_text=source_text,
+            created_by=actor,
+            category=category,
+            source_language=source_language,
+            generated_kind=generated_kind,
+            receipt_claims=claims,
+            receipt_hash=receipt_hash,
+        )
+
+    def validate(
+        self,
+        version_id: str,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        version = _uid(version_id, "version_id")
+        actor = _uid(actor_uid, "actor_uid")
+        context = self.repository.load_logical_validation_context(
+            version_id=version,
+        )
+        try:
+            compiled = _normalize_compiled(self.compiler.compile(context))
+        except ValueError as exc:
+            self.repository.record_validation_failure(
+                version_id=version,
+                actor_uid=actor,
+                error_code="deterministic_validation_error",
+                error_hash=hashlib.sha256(str(exc).encode("utf-8")).hexdigest(),
+            )
+            raise RuleValidationRejected(str(exc)) from exc
+        return self.repository.persist_logical_compile(
+            version_id=version,
+            actor_uid=actor,
+            context=context,
+            compiled=compiled,
+        )
+
+    def test(
+        self,
+        version_id: str,
+        actor_uid: str,
+        *,
+        plan_id: str,
+    ) -> dict[str, Any]:
+        version = _uid(version_id, "version_id")
+        actor = _uid(actor_uid, "actor_uid")
+        plan = _uid(plan_id, "plan_id")
+        context = self.repository.load_logical_test_context(
+            version_id=version,
+            plan_id=plan,
+        )
+        if "terminal_result" in context:
+            return copy.deepcopy(context["terminal_result"])
+        evidence = _normalize_test(self.test_runner.run(context), context)
+        return self.repository.persist_logical_test(
+            version_id=version,
+            plan_id=plan,
+            actor_uid=actor,
+            context=context,
+            evidence=evidence,
+        )
+
+    def publish(self, version_id: str, actor_uid: str) -> dict[str, Any]:
+        return self.repository.publish_validated_version(
+            version_id=_uid(version_id, "version_id"),
+            actor_uid=_uid(actor_uid, "actor_uid"),
+        )
+
+    def evidence(self, version_id: str) -> dict[str, Any]:
+        return self.repository.get_publication_evidence(
+            version_id=_uid(version_id, "version_id")
+        )
+
+    def catalog(self, *, query: str = "", limit: int = 50):
+        if not isinstance(query, str) or len(query) > 200:
+            raise ValueError("catalog query is invalid")
+        if (
+            isinstance(limit, bool)
+            or not isinstance(limit, int)
+            or limit < 1
+            or limit > 100
+        ):
+            raise ValueError("catalog limit is invalid")
+        return self.repository.search_published_rules(
+            query=query.strip(), limit=limit
+        )
+
+
+class PhysicalPlanPublicationService:
+    """Test and publish deployment-bound plans independently of RuleVersion."""
+
+    def __init__(self, repository, *, test_runner):
+        self.repository = repository
+        self.test_runner = test_runner
+
+    def validate(self, plan_id: str, actor_uid: str) -> dict[str, Any]:
+        return self.repository.attest_physical_compile(
+            plan_id=_uid(plan_id, "plan_id"),
+            actor_uid=_uid(actor_uid, "actor_uid"),
+        )
+
+    def test(self, plan_id: str, actor_uid: str) -> dict[str, Any]:
+        plan = _uid(plan_id, "plan_id")
+        actor = _uid(actor_uid, "actor_uid")
+        context = self.repository.load_physical_test_context(plan_id=plan)
+        if "terminal_result" in context:
+            return copy.deepcopy(context["terminal_result"])
+        evidence = _normalize_test(self.test_runner.run(context), context)
+        return self.repository.persist_physical_test(
+            plan_id=plan,
+            actor_uid=actor,
+            context=context,
+            evidence=evidence,
+        )
+
+    def publish(self, plan_id: str, actor_uid: str) -> dict[str, Any]:
+        return self.repository.publish_tested_physical_plan(
+            plan_id=_uid(plan_id, "plan_id"),
+            actor_uid=_uid(actor_uid, "actor_uid"),
+        )
+
+
+class CanonicalBoundCompiler:
+    """Compile a repository-loaded physical context through the registry."""
+
+    def __init__(self, compiler_registry):
+        self.compiler_registry = compiler_registry
+
+    def compile(self, context: dict[str, Any]) -> dict[str, Any]:
+        if not isinstance(context, dict):
+            raise ValueError("canonical publication context is invalid")
+        try:
+            rule_version = context["rule_version"]
+            input_schema = context["input_schema"]
+            output_schema = context["output_schema"]
+            input_binding = context["input_binding"]
+            output_binding = context["output_binding"]
+            backend = context["backend"]
+        except KeyError as exc:
+            raise ValueError(
+                "canonical publication context is incomplete"
+            ) from exc
+        compiler = self.compiler_registry.select(
+            rule_version.get("rule_spec"),
+            input_binding,
+            output_binding,
+        )
+        compiled = compiler.compile(
+            rule_version=rule_version,
+            input_schema=input_schema,
+            output_schema=output_schema,
+            input_binding=input_binding,
+            output_binding=output_binding,
+            backend=backend,
+        )
+        plan = compiled.get("plan")
+        if not isinstance(plan, dict):
+            raise ValueError("trusted compiler returned an invalid plan")
+        return {
+            "backend": compiled.get("backend"),
+            "compiler_version": compiled.get("compiler_version"),
+            "plan": plan,
+            "plan_hash": compiled.get("plan_hash"),
+            "schema_hashes": {
+                "rule_spec_hash": rule_version.get("spec_hash"),
+                "input_schema_snapshot_id": input_schema.get("id"),
+                "input_schema_hash": input_schema.get("schema_hash"),
+                "output_schema_snapshot_id": output_schema.get("id"),
+                "output_schema_hash": output_schema.get("schema_hash"),
+            },
+            "binding_hashes": {
+                "input": input_binding.get("binding_hash"),
+                "output": output_binding.get("binding_hash"),
+            },
+            "capabilities": copy.deepcopy(backend),
+        }
+
+
+class LogicalRuleCompiler:
+    """Compile a draft RuleSpec against its immutable validation profile."""
+
+    VERSION = "dataops-logical-rule-1.0"
+
+    def compile(self, context: dict[str, Any]) -> dict[str, Any]:
+        from app.core.data_rules.compilers.polars import PolarsRuleCompiler
+        from app.core.data_rules.contracts import read_rule_spec
+
+        if not isinstance(context, dict):
+            raise ValueError("logical validation context is invalid")
+        version = context.get("version")
+        profile = context.get("profile")
+        if not isinstance(version, dict) or version.get("status") != "draft":
+            raise ValueError("only draft rule versions may be validated")
+        if not isinstance(profile, dict):
+            raise ValueError("trusted validation profile is unavailable")
+        spec = read_rule_spec(version.get("rule_spec"))
+        if rule_spec_hash(spec) != version.get("spec_hash"):
+            raise ValueError("canonical RuleSpec hash does not match")
+        fields = profile.get("fields")
+        if not isinstance(fields, list) or not fields:
+            raise ValueError("trusted validation schema fields are unavailable")
+        schema_hash = _digest(profile.get("schema_hash"), "schema_hash")
+        snapshot = {
+            "id": _uid(
+                profile.get("schema_snapshot_id"), "schema_snapshot_id"
+            ),
+            "schema_ref": str(profile.get("schema_ref") or ""),
+            "schema_hash": schema_hash,
+            "fields": copy.deepcopy(fields),
+            "source_revision": str(profile.get("source_revision") or ""),
+        }
+        version_id = _uid(version.get("id"), "version_id")
+        profile_id = _uid(profile.get("id"), "validation_profile_id")
+        binding_base = {
+            "data_source_uid": version_id,
+            "object_kind": "parquet_artifact",
+            "schema_snapshot_id": snapshot["id"],
+            "access_mode": "read_write",
+            "dialect": "polars",
+            "write_mode": "append",
+        }
+        compiled = PolarsRuleCompiler().compile(
+            rule_version={**version, "status": "published"},
+            input_schema=snapshot,
+            output_schema=snapshot,
+            input_binding={
+                "id": profile_id,
+                **binding_base,
+                "object_ref": "validation-input",
+            },
+            output_binding={
+                "id": version_id,
+                **binding_base,
+                "object_ref": "validation-output",
+            },
+            backend={
+                "max_rows": 100_000,
+                "max_artifact_bytes": 32 * 1024 * 1024,
+                "memory_limit_bytes": 256 * 1024 * 1024,
+                "masking_policies": {
+                    "customer_mobile_last4": "preserve_last_4",
+                    "redact": "redact",
+                },
+                "lookup_bindings": {},
+            },
+        )
+        plan = compiled["plan"]
+        return {
+            "backend": "polars_batch",
+            "compiler_version": compiled["compiler_version"],
+            "plan": plan,
+            "plan_hash": compiled["plan_hash"],
+            "schema_hashes": {"input": schema_hash},
+            "binding_hashes": {},
+            "capabilities": {
+                "supported_execution_backends": ["polars"],
+                "closed_rulespec": "2.0",
+                "resource_limits": plan["resource_limits"],
+            },
+        }
+
+
+class ServerOwnedLogicalDryRunRunner:
+    """Execute a logical Polars plan on a bounded server-owned artifact."""
+
+    def __init__(self, artifact_store):
+        self.artifact_store = artifact_store
+
+    def run(self, context: dict[str, Any]) -> dict[str, Any]:
+        from app.core.data_rules.compilers.polars import (
+            validate_bound_polars_plan,
+        )
+        from app.runner.polars_worker import execute_isolated_polars_plan
+
+        sample_ref = context.get("sample_artifact_ref")
+        if not isinstance(sample_ref, str) or not sample_ref:
+            raise ValueError("server-owned sample artifact is required")
+        sample_digest = _digest(
+            context.get("sample_artifact_digest"),
+            "sample_artifact_digest",
+        )
+        plan = validate_bound_polars_plan(context.get("plan"))
+        described = self.artifact_store.describe(sample_ref)
+        if (
+            described["digest"] != sample_digest
+            or described["schema_hash"] != plan["input_schema_hash"]
+        ):
+            raise ValueError("sample artifact schema has drifted")
+        output_path = None
+        try:
+            with self.artifact_store.stage(
+                sample_ref,
+                sample_digest,
+                expected_schema_fields=plan["input_fields"],
+                limits=plan["resource_limits"],
+            ) as input_path:
+                with tempfile.NamedTemporaryFile(
+                    prefix="dataops-logical-dry-run-",
+                    suffix=".parquet",
+                    delete=False,
+                ) as handle:
+                    output_path = handle.name
+                result = execute_isolated_polars_plan(
+                    {
+                        "plan": plan,
+                        "input_path": input_path,
+                        "lookup_paths": {},
+                        "output_path": output_path,
+                        "masking_policies": {
+                            "customer_mobile_last4": "preserve_last_4",
+                            "redact": "redact",
+                        },
+                    },
+                    memory_limit_bytes=plan["resource_limits"][
+                        "memory_limit_bytes"
+                    ],
+                )
+            prepared = self.artifact_store.prepare_path(
+                output_path,
+                new_governance_uid(),
+                300,
+                schema_fields=plan["output_fields"],
+                limits=plan["resource_limits"],
+            )
+        finally:
+            if output_path is not None:
+                with suppress(FileNotFoundError):
+                    os.unlink(output_path)
+        count_keys = (
+            "rows_in",
+            "rows_out",
+            "rows_rejected",
+            "rows_quarantined",
+            "rows_filtered",
+            "rows_deduplicated",
+            "rows_join_dropped",
+            "rows_aggregated",
+            "violation_count",
+        )
+        return {
+            "status": "success",
+            "test_kind": "dry_run",
+            "run_id": new_governance_uid(),
+            "plan_hash": context["plan_hash"],
+            "schema_hashes": context["schema_hashes"],
+            "binding_hashes": context["binding_hashes"],
+            "counts": {key: int(result.get(key, 0)) for key in count_keys},
+            "attestation": {
+                "input_digest": described["digest"],
+                "output_digest": prepared["digest"],
+                "output_schema_hash": prepared["schema_hash"],
+                "violation_digest": hashlib.sha256(
+                    _canonical(result.get("violations", []))
+                ).hexdigest(),
+            },
+        }
+
+
+class ServerOwnedPhysicalPreflightRunner:
+    """Execute an exact physical Polars plan on its latest trusted input."""
+
+    def __init__(self, artifact_store, datasource_manager=None):
+        self.artifact_store = artifact_store
+        self.datasource_manager = datasource_manager
+
+    def run(self, context: dict[str, Any]) -> dict[str, Any]:
+        from app.core.data_rules.compilers.polars import (
+            validate_bound_polars_plan,
+        )
+        from app.runner.polars_worker import execute_isolated_polars_plan
+
+        if context.get("backend") == "sql_pushdown":
+            return self._run_sql(context)
+        if context.get("backend") != "polars_batch":
+            raise ValueError("physical preflight backend is unsupported")
+        sample = context.get("sample_artifact")
+        if (
+            not isinstance(sample, dict)
+            or set(sample) != {
+                "artifact_ref",
+                "digest",
+                "schema_fields",
+            }
+        ):
+            raise ValueError("server-owned physical sample is required")
+        plan = validate_bound_polars_plan(context.get("plan"))
+        output_path = None
+        try:
+            with self.artifact_store.stage(
+                sample["artifact_ref"],
+                sample["digest"],
+                expected_schema_fields=sample["schema_fields"],
+                limits=plan["resource_limits"],
+            ) as input_path:
+                with tempfile.NamedTemporaryFile(
+                    prefix="dataops-physical-preflight-",
+                    suffix=".parquet",
+                    delete=False,
+                ) as handle:
+                    output_path = handle.name
+                result = execute_isolated_polars_plan(
+                    {
+                        "plan": plan,
+                        "input_path": input_path,
+                        "lookup_paths": {},
+                        "output_path": output_path,
+                        "masking_policies": {
+                            "customer_mobile_last4": "preserve_last_4",
+                            "redact": "redact",
+                        },
+                    },
+                    memory_limit_bytes=plan["resource_limits"][
+                        "memory_limit_bytes"
+                    ],
+                )
+            prepared = self.artifact_store.prepare_path(
+                output_path,
+                new_governance_uid(),
+                300,
+                schema_fields=plan["output_fields"],
+                limits=plan["resource_limits"],
+            )
+        finally:
+            if output_path is not None:
+                with suppress(FileNotFoundError):
+                    os.unlink(output_path)
+        count_keys = (
+            "rows_in",
+            "rows_out",
+            "rows_rejected",
+            "rows_quarantined",
+            "rows_filtered",
+            "rows_deduplicated",
+            "rows_join_dropped",
+            "rows_aggregated",
+            "violation_count",
+        )
+        return {
+            "status": "success",
+            "test_kind": "dry_run",
+            "run_id": new_governance_uid(),
+            "plan_hash": context["plan_hash"],
+            "schema_hashes": context["schema_hashes"],
+            "binding_hashes": context["binding_hashes"],
+            "counts": {key: int(result.get(key, 0)) for key in count_keys},
+            "attestation": {
+                "input_digest": sample["digest"],
+                "output_digest": prepared["digest"],
+                "output_schema_hash": prepared["schema_hash"],
+                "violation_digest": hashlib.sha256(
+                    _canonical(result.get("violations", []))
+                ).hexdigest(),
+            },
+        }
+
+    def _run_sql(self, context: dict[str, Any]) -> dict[str, Any]:
+        from sqlalchemy import text
+
+        from app.core.data_rules.compilers.sql import validate_bound_sql_plan
+
+        if self.datasource_manager is None:
+            raise ValueError(
+                "server-owned SQL preflight executor is not configured"
+            )
+        plan = validate_bound_sql_plan(context.get("plan"))
+        statement = plan["statements"][0]
+        explain_sql = f"EXPLAIN {statement['sql']}"
+        with self.datasource_manager.connect(
+            plan["data_source_uid"], purpose="dataflow_read"
+        ) as connection:
+            rows = connection.execute(
+                text(explain_sql), statement["parameters"]
+            ).fetchall()
+        if not rows:
+            raise ValueError("physical SQL preflight returned no explain plan")
+        explain_digest = hashlib.sha256(
+            _canonical([list(row) for row in rows])
+        ).hexdigest()
+        return {
+            "status": "success",
+            "test_kind": "dry_run",
+            "run_id": new_governance_uid(),
+            "plan_hash": context["plan_hash"],
+            "schema_hashes": context["schema_hashes"],
+            "binding_hashes": context["binding_hashes"],
+            "counts": {
+                "rows_in": 0,
+                "rows_out": 0,
+                "rows_rejected": 0,
+            },
+            "attestation": {
+                "dialect": plan["dialect"],
+                "explain_digest": explain_digest,
+                "statement_digest": hashlib.sha256(
+                    statement["sql"].encode("utf-8")
+                ).hexdigest(),
+            },
+        }
+
+
+__all__ = [
+    "CanonicalBoundCompiler",
+    "GenerationReceiptSigner",
+    "LogicalRuleCompiler",
+    "PhysicalPlanPublicationService",
+    "RulePublicationService",
+    "RuleValidationRejected",
+    "ServerOwnedLogicalDryRunRunner",
+    "ServerOwnedPhysicalPreflightRunner",
+    "generation_receipt_claims",
+]

Разница между файлами не показана из-за своего большого размера
+ 926 - 26
app/core/data_rules/repository.py


+ 5 - 1
app/runner/polars_worker.py

@@ -208,6 +208,7 @@ def _execute_polars_job(job: dict[str, Any]) -> dict[str, Any]:
     violation_sample = []
     metrics = {
         "rows_rejected": 0,
+        "rows_quarantined": 0,
         "rows_filtered": 0,
         "rows_deduplicated": 0,
         "rows_join_dropped": 0,
@@ -277,7 +278,10 @@ def _execute_polars_job(job: dict[str, Any]) -> dict[str, Any]:
             violations.append(
                 {"step_id": operation["step_id"], "count": invalid}
             )
-            metrics["rows_rejected"] += invalid
+            if operation["on_failure"] == "quarantine":
+                metrics["rows_quarantined"] += invalid
+            else:
+                metrics["rows_rejected"] += invalid
             frame = frame.filter(predicate)
         elif op == "derive":
             frame = frame.with_columns(

+ 1 - 0
app/runner/rule_polars.py

@@ -36,6 +36,7 @@ _RESULT_KEYS = (
     "rows_in",
     "rows_out",
     "rows_rejected",
+    "rows_quarantined",
     "rows_filtered",
     "rows_deduplicated",
     "rows_join_dropped",

+ 223 - 0
migrations/versions/20260723_200_rule_publication_gates.py

@@ -0,0 +1,223 @@
+"""Add trusted generation receipts and compile/test publication gates."""
+
+from alembic import op
+
+revision = "20260723_200"
+down_revision = "20260723_190"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        DO $$
+        BEGIN
+            IF EXISTS (
+                SELECT 1 FROM public.data_rule_versions
+                WHERE status NOT IN ('draft','validated','published')
+            ) THEN
+                RAISE EXCEPTION
+                    'legacy rule version statuses must be resolved before 200';
+            END IF;
+        END $$;
+
+        ALTER TABLE public.data_rule_versions
+            DROP CONSTRAINT data_rule_versions_status_check;
+        ALTER TABLE public.data_rule_versions
+            ADD CONSTRAINT data_rule_versions_status_check
+            CHECK (status IN ('draft','validated','published','revoked'));
+
+        ALTER TABLE public.rule_execution_plans
+            DROP CONSTRAINT rule_execution_plans_status_check;
+        ALTER TABLE public.rule_execution_plans
+            ADD CONSTRAINT rule_execution_plans_status_check
+            CHECK (status IN ('compiled','tested','published','revoked'));
+
+        ALTER TABLE public.rule_generation_runs
+            ADD COLUMN receipt_hash CHAR(64),
+            ADD COLUMN receipt_consumed_at TIMESTAMPTZ,
+            ADD COLUMN validation_context JSONB,
+            ADD COLUMN model_hash CHAR(64),
+            ADD COLUMN prompt_hash CHAR(64);
+        CREATE UNIQUE INDEX uq_rule_generation_linked_version
+            ON public.rule_generation_runs(rule_version_id)
+            WHERE rule_version_id IS NOT NULL;
+        CREATE UNIQUE INDEX uq_rule_generation_receipt_hash
+            ON public.rule_generation_runs(receipt_hash)
+            WHERE receipt_hash IS NOT NULL;
+
+        CREATE TABLE public.rule_generation_attempts (
+            id UUID PRIMARY KEY,
+            generation_run_id UUID NOT NULL
+                REFERENCES public.rule_generation_runs(id) ON DELETE RESTRICT,
+            attempt_no INTEGER NOT NULL CHECK (attempt_no BETWEEN 0 AND 2),
+            candidate_hash CHAR(64) NOT NULL,
+            candidate JSONB,
+            error_code VARCHAR(80),
+            model_hash CHAR(64) NOT NULL,
+            prompt_hash CHAR(64) NOT NULL,
+            context_hash CHAR(64) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('valid','invalid')),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (generation_run_id, attempt_no)
+        );
+
+        CREATE TABLE public.rule_validation_profiles (
+            id UUID PRIMARY KEY,
+            rule_version_id UUID NOT NULL UNIQUE
+                REFERENCES public.data_rule_versions(id) ON DELETE RESTRICT,
+            schema_snapshot_id UUID NOT NULL
+                REFERENCES public.data_schema_snapshots(id) ON DELETE RESTRICT,
+            schema_hash CHAR(64) NOT NULL,
+            sample_artifact_ref VARCHAR(500),
+            context_hash CHAR(64) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.rule_logical_plans (
+            id UUID PRIMARY KEY,
+            rule_version_id UUID NOT NULL
+                REFERENCES public.data_rule_versions(id) ON DELETE RESTRICT,
+            validation_profile_id UUID NOT NULL
+                REFERENCES public.rule_validation_profiles(id)
+                ON DELETE RESTRICT,
+            compiler_version VARCHAR(80) NOT NULL,
+            backend VARCHAR(30) NOT NULL CHECK (
+                backend IN ('sql_pushdown','polars_batch')
+            ),
+            plan JSONB NOT NULL,
+            plan_hash CHAR(64) NOT NULL,
+            schema_hashes JSONB NOT NULL,
+            capabilities JSONB NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('compiled','tested','published','revoked')
+            ),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (rule_version_id, plan_hash)
+        );
+
+        CREATE TABLE public.rule_logical_compile_evidence (
+            id UUID PRIMARY KEY,
+            logical_plan_id UUID NOT NULL
+                REFERENCES public.rule_logical_plans(id) ON DELETE RESTRICT,
+            compiler_version VARCHAR(80) NOT NULL,
+            compiler_digest CHAR(64) NOT NULL,
+            plan_hash CHAR(64) NOT NULL,
+            schema_hashes JSONB NOT NULL,
+            capabilities JSONB NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('success','failed')),
+            created_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (logical_plan_id, compiler_digest)
+        );
+
+        CREATE TABLE public.rule_logical_test_evidence (
+            id UUID PRIMARY KEY,
+            logical_plan_id UUID NOT NULL
+                REFERENCES public.rule_logical_plans(id) ON DELETE RESTRICT,
+            test_kind VARCHAR(40) NOT NULL,
+            evidence_hash CHAR(64) NOT NULL,
+            run_id UUID NOT NULL,
+            plan_hash CHAR(64) NOT NULL,
+            schema_hashes JSONB NOT NULL,
+            evidence JSONB NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('success','failed')),
+            created_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (logical_plan_id, test_kind, evidence_hash)
+        );
+
+        ALTER TABLE public.rule_compile_evidence
+            ADD COLUMN plan_hash CHAR(64),
+            ADD COLUMN schema_hashes JSONB,
+            ADD COLUMN binding_hashes JSONB,
+            ADD COLUMN capabilities JSONB,
+            ADD COLUMN created_by UUID REFERENCES public.users(id)
+                ON DELETE SET NULL;
+        UPDATE public.rule_compile_evidence e
+        SET plan_hash = p.plan_hash,
+            schema_hashes = p.schema_hashes,
+            binding_hashes = jsonb_build_object(
+                'input', COALESCE(p.plan->>'input_binding_hash', ''),
+                'output', COALESCE(p.plan->>'output_binding_hash', '')
+            ),
+            capabilities = COALESCE(
+                p.plan->'capabilities',
+                jsonb_build_object('backend', p.backend)
+            )
+        FROM public.rule_execution_plans p
+        WHERE p.id = e.rule_execution_plan_id;
+        ALTER TABLE public.rule_compile_evidence
+            ALTER COLUMN plan_hash SET NOT NULL,
+            ALTER COLUMN schema_hashes SET NOT NULL,
+            ALTER COLUMN binding_hashes SET NOT NULL,
+            ALTER COLUMN capabilities SET NOT NULL;
+
+        ALTER TABLE public.rule_test_evidence
+            ADD COLUMN plan_hash CHAR(64),
+            ADD COLUMN schema_hashes JSONB,
+            ADD COLUMN binding_hashes JSONB,
+            ADD COLUMN run_id UUID,
+            ADD COLUMN created_by UUID REFERENCES public.users(id)
+                ON DELETE SET NULL;
+        UPDATE public.rule_test_evidence e
+        SET plan_hash = p.plan_hash,
+            schema_hashes = p.schema_hashes,
+            binding_hashes = jsonb_build_object(
+                'input', COALESCE(p.plan->>'input_binding_hash', ''),
+                'output', COALESCE(p.plan->>'output_binding_hash', '')
+            ),
+            run_id = e.id
+        FROM public.rule_execution_plans p
+        WHERE p.id = e.rule_execution_plan_id;
+        ALTER TABLE public.rule_test_evidence
+            ALTER COLUMN plan_hash SET NOT NULL,
+            ALTER COLUMN schema_hashes SET NOT NULL,
+            ALTER COLUMN binding_hashes SET NOT NULL,
+            ALTER COLUMN run_id SET NOT NULL;
+
+        CREATE TABLE public.rule_validation_attempts (
+            id UUID PRIMARY KEY,
+            rule_version_id UUID NOT NULL
+                REFERENCES public.data_rule_versions(id) ON DELETE RESTRICT,
+            actor_uid UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            error_code VARCHAR(80) NOT NULL,
+            error_hash CHAR(64) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.rule_publication_audits (
+            id UUID PRIMARY KEY,
+            rule_version_id UUID NOT NULL
+                REFERENCES public.data_rule_versions(id) ON DELETE RESTRICT,
+            rule_execution_plan_id UUID
+                REFERENCES public.rule_execution_plans(id) ON DELETE RESTRICT,
+            actor_uid UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            action VARCHAR(30) NOT NULL CHECK (
+                action IN (
+                    'draft_created','compile_succeeded','test_succeeded',
+                    'published','revoked'
+                )
+            ),
+            from_status VARCHAR(20),
+            to_status VARCHAR(20) NOT NULL,
+            evidence_hash CHAR(64),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_rule_publication_audit_version
+            ON public.rule_publication_audits(
+                rule_version_id, created_at DESC
+            );
+        CREATE INDEX idx_published_rule_catalog
+            ON public.data_rule_versions(status, published_at DESC)
+            WHERE status = 'published';
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "trusted rule publication evidence is forward-only and cannot be "
+        "downgraded without invalidating signed receipts and audit history"
+    )

+ 63 - 1
tests/core/data_rules/test_authoring.py

@@ -6,7 +6,6 @@ import re
 import pytest
 
 from app.core.common.identifiers import new_governance_uid
-
 from tests.core.data_rules.test_contracts import valid_rule_spec
 
 
@@ -27,6 +26,17 @@ class FakeModel:
         return json.dumps(self.payload, ensure_ascii=False)
 
 
+class SequencedModel(FakeModel):
+    def __init__(self, payloads):
+        super().__init__(None)
+        self.payloads = list(payloads)
+
+    def generate(self, **kwargs):
+        self.calls.append(kwargs)
+        payload = self.payloads.pop(0)
+        return payload if isinstance(payload, str) else json.dumps(payload)
+
+
 def valid_candidate():
     return {
         "schema_version": "1.0",
@@ -125,3 +135,55 @@ def test_authoring_rejects_secret_or_unbounded_context_before_model_call():
             context={"metadata": "x" * 70_000},
         )
     assert model.calls == []
+
+
+def test_authoring_repairs_only_deterministic_contract_errors_twice():
+    from app.core.data_rules.authoring import RuleAuthoringAgent
+
+    model = SequencedModel(
+        [
+            "not-json",
+            {"schema_version": "1.0"},
+            valid_candidate(),
+        ]
+    )
+    result = RuleAuthoringAgent(model=model).interpret(
+        source_text="手机号必须为11位",
+        authoring_surface="data_standard",
+        context={"input_schema_ref": "bd:customer:v7"},
+    )
+
+    assert result["status"] == "ready"
+    assert result["repair_attempts"] == 2
+    assert len(result["generation_attempts"]) == 3
+    assert [item["status"] for item in result["generation_attempts"]] == [
+        "invalid",
+        "invalid",
+        "valid",
+    ]
+    assert all(
+        re.fullmatch(r"[0-9a-f]{64}", item["candidate_hash"])
+        for item in result["generation_attempts"]
+    )
+    assert "DETERMINISTIC_VALIDATION_ERROR" in model.calls[1]["messages"][-1][
+        "content"
+    ]
+
+
+def test_authoring_does_not_repair_ambiguity_or_low_confidence():
+    from app.core.data_rules.authoring import RuleAuthoringAgent
+
+    candidate = valid_candidate()
+    candidate["ambiguities"] = ["字段语义不明确"]
+    candidate["confidence"] = 0.2
+    model = SequencedModel([candidate, valid_candidate()])
+
+    result = RuleAuthoringAgent(model=model).interpret(
+        source_text="清洗它",
+        authoring_surface="data_flow",
+        context={"input_schema_ref": "bd:customer:v7"},
+    )
+
+    assert result["status"] == "clarification_required"
+    assert result["repair_attempts"] == 0
+    assert len(model.calls) == 1

+ 11 - 32
tests/core/data_rules/test_data_rule_repository.py

@@ -98,7 +98,7 @@ def _sql(session):
     return "\n".join(statement for statement, _params in session.calls)
 
 
-def test_create_rule_version_is_validated_immutable_and_idempotent():
+def test_create_rule_version_is_draft_immutable_and_idempotent():
     from app.core.data_rules.repository import DataRuleRepository
 
     session = FakeSession()
@@ -113,7 +113,7 @@ def test_create_rule_version_is_validated_immutable_and_idempotent():
 
     assert result["created"] is True
     assert result["version_no"] == 3
-    assert result["status"] == "validated"
+    assert result["status"] == "draft"
     assert result["spec_hash"] == rule_spec_hash(spec)
     assert "INSERT INTO public.data_rules" in _sql(session)
     assert "INSERT INTO public.data_rule_versions" in _sql(session)
@@ -161,40 +161,17 @@ def test_create_rule_version_rejects_legacy_v1_rule_specs():
         )
 
 
-def test_publish_rule_version_only_transitions_validated_once():
+def test_publish_rule_version_cannot_bypass_compile_and_test_gate():
     from app.core.data_rules.repository import DataRuleRepository
 
     version_id = new_governance_uid()
-    published = {
-        "id": version_id,
-        "rule_uid": new_governance_uid(),
-        "version_no": 2,
-        "status": "published",
-        "spec_hash": "a" * 64,
-    }
-    session = FakeSession(publish_row=published)
-
-    assert (
+    session = FakeSession()
+    with pytest.raises(ValueError, match="not found"):
         DataRuleRepository(session).publish_rule_version(
             version_id=version_id,
             published_by=new_governance_uid(),
         )
-        == published
-    )
-    update = next(
-        statement
-        for statement, _params in session.calls
-        if "UPDATE public.data_rule_versions" in statement
-    )
-    assert "status = 'validated'" in update
-    assert "status = 'published'" in update
-
-    immutable = FakeSession(current_status="published")
-    with pytest.raises(ValueError, match="already published"):
-        DataRuleRepository(immutable).publish_rule_version(
-            version_id=version_id,
-            published_by=new_governance_uid(),
-        )
+    assert "publication_gate_state" in _sql(session)
 
 
 def test_standard_version_requires_published_rule_versions_and_fixed_bindings():
@@ -305,9 +282,11 @@ def test_generation_run_persists_model_hashes_uncertainty_and_decision():
         "model_name": "qwen3",
         "prompt_version": "data-rule-authoring-v1",
         "schema_version": "1.0",
-        "context_hash": "a" * 64,
-        "candidate_hash": DataRuleRepository.candidate_hash(candidate),
-    }
+            "context_hash": "a" * 64,
+            "candidate_hash": DataRuleRepository.candidate_hash(candidate),
+            "model_hash": "b" * 64,
+            "prompt_hash": "c" * 64,
+        }
 
     result = DataRuleRepository(session).record_generation_run(
         evidence=evidence

+ 361 - 0
tests/core/data_rules/test_publication.py

@@ -0,0 +1,361 @@
+from __future__ import annotations
+
+import copy
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_rules.contracts import rule_spec_hash
+from tests.core.data_rules.test_contracts import valid_rule_spec
+
+
+class MemoryPublicationRepository:
+    def __init__(self):
+        self.version_id = new_governance_uid()
+        self.plan_id = new_governance_uid()
+        self.version_status = "draft"
+        self.plan_status = "compiled"
+        self.compile_evidence = None
+        self.test_evidence = None
+        self.receipt_claims = None
+        self.published_by = None
+        self.validation_failures = []
+
+    def create_draft_from_generation(self, **kwargs):
+        self.receipt_claims = kwargs["receipt_claims"]
+        return {
+            "id": self.version_id,
+            "rule_uid": kwargs["rule_spec"]["rule_uid"],
+            "version_no": 1,
+            "status": "draft",
+            "spec_hash": rule_spec_hash(kwargs["rule_spec"]),
+            "generation_run_id": kwargs["receipt_claims"]["generation_run_id"],
+            "created": True,
+        }
+
+    def load_logical_validation_context(self, *, version_id):
+        assert version_id == self.version_id
+        return {
+            "version": {
+                "id": self.version_id,
+                "status": self.version_status,
+                "spec_hash": "a" * 64,
+            },
+            "profile": {
+                "schema_snapshot_id": new_governance_uid(),
+                "schema_hash": "b" * 64,
+                "fields": [{"name": "mobile", "type": "string"}],
+                "sample_artifact_ref": None,
+            },
+        }
+
+    def persist_logical_compile(self, **kwargs):
+        self.compile_evidence = kwargs
+        return {
+            "version_id": self.version_id,
+            "version_status": self.version_status,
+            "plan_id": self.plan_id,
+            "plan_status": self.plan_status,
+            "plan_hash": kwargs["compiled"]["plan_hash"],
+            "compile_evidence_id": new_governance_uid(),
+        }
+
+    def record_validation_failure(self, **kwargs):
+        self.validation_failures.append(kwargs)
+
+    def load_logical_test_context(self, *, version_id, plan_id):
+        assert version_id == self.version_id
+        assert plan_id == self.plan_id
+        return {
+            "version_id": version_id,
+            "version_status": self.version_status,
+            "plan_id": plan_id,
+            "plan_status": self.plan_status,
+            "plan_hash": self.compile_evidence["compiled"]["plan_hash"],
+            "schema_hashes": {"input": "b" * 64, "output": "c" * 64},
+            "binding_hashes": {},
+        }
+
+    def persist_logical_test(self, **kwargs):
+        self.test_evidence = kwargs
+        self.plan_status = "tested"
+        self.version_status = "validated"
+        return {
+            "version_id": self.version_id,
+            "version_status": self.version_status,
+            "plan_id": self.plan_id,
+            "plan_status": self.plan_status,
+            "plan_hash": kwargs["context"]["plan_hash"],
+            "test_evidence_id": new_governance_uid(),
+        }
+
+    def publish_validated_version(self, *, version_id, actor_uid):
+        if (
+            self.version_status != "validated"
+            or self.plan_status != "tested"
+            or self.compile_evidence is None
+            or self.test_evidence is None
+        ):
+            raise ValueError("compile and test evidence is required")
+        self.version_status = "published"
+        self.plan_status = "published"
+        self.published_by = actor_uid
+        return {
+            "id": version_id,
+            "status": "published",
+            "plan_id": self.plan_id,
+            "plan_status": "published",
+        }
+
+    def get_publication_evidence(self, *, version_id):
+        return {
+            "version_id": version_id,
+            "version_status": self.version_status,
+            "compile": self.compile_evidence,
+            "test": self.test_evidence,
+        }
+
+    def search_published_rules(self, *, query, limit):
+        if self.version_status != "published":
+            return []
+        return [{"id": self.version_id, "name": "mobile", "status": "published"}]
+
+
+class DeterministicCompiler:
+    def compile(self, context):
+        assert context["version"]["status"] == "draft"
+        return {
+            "backend": "polars_batch",
+            "compiler_version": "test-compiler-1",
+            "plan": {"closed": True},
+            "plan_hash": "f" * 64,
+            "schema_hashes": {"input": "b" * 64, "output": "c" * 64},
+            "binding_hashes": {},
+            "capabilities": {"polars": "1.42.1"},
+        }
+
+
+class DeterministicDryRunner:
+    def run(self, context):
+        return {
+            "status": "success",
+            "test_kind": "dry_run",
+            "run_id": new_governance_uid(),
+            "plan_hash": context["plan_hash"],
+            "schema_hashes": context["schema_hashes"],
+            "binding_hashes": context["binding_hashes"],
+            "counts": {"rows_in": 2, "rows_out": 2, "rows_rejected": 0},
+            "attestation": {"result_digest": "a" * 64},
+        }
+
+
+def _receipt_claims(spec, actor):
+    from app.core.data_rules.publication import generation_receipt_claims
+
+    return generation_receipt_claims(
+        generation_run_id=new_governance_uid(),
+        actor_uid=actor,
+        source_text="手机号必须为11位数字",
+        candidate_hash="1" * 64,
+        rule_spec=spec,
+        model_hash="2" * 64,
+        prompt_hash="3" * 64,
+        context_hash="4" * 64,
+        expires_at=datetime.now(UTC) + timedelta(minutes=5),
+    )
+
+
+def test_generation_receipt_binds_actor_source_candidate_spec_and_model_context():
+    from app.core.data_rules.publication import GenerationReceiptSigner
+
+    spec = valid_rule_spec()
+    actor = new_governance_uid()
+    claims = _receipt_claims(spec, actor)
+    signer = GenerationReceiptSigner("test-secret-with-sufficient-entropy")
+    receipt = signer.issue(claims)
+
+    assert signer.verify(
+        receipt,
+        actor_uid=actor,
+        source_text="手机号必须为11位数字",
+        rule_spec=spec,
+    ) == claims
+
+    tampered = copy.deepcopy(spec)
+    tampered["name"] = "different"
+    with pytest.raises(ValueError, match="receipt"):
+        signer.verify(
+            receipt,
+            actor_uid=actor,
+            source_text="手机号必须为11位数字",
+            rule_spec=tampered,
+        )
+    with pytest.raises(ValueError, match="receipt"):
+        signer.verify(
+            receipt,
+            actor_uid=new_governance_uid(),
+            source_text="手机号必须为11位数字",
+            rule_spec=spec,
+        )
+
+
+def test_generation_receipt_rejects_expired_and_modified_signatures():
+    from app.core.data_rules.publication import (
+        GenerationReceiptSigner,
+        generation_receipt_claims,
+    )
+
+    spec = valid_rule_spec()
+    actor = new_governance_uid()
+    signer = GenerationReceiptSigner("test-secret-with-sufficient-entropy")
+    claims = generation_receipt_claims(
+        generation_run_id=new_governance_uid(),
+        actor_uid=actor,
+        source_text="手机号必须为11位数字",
+        candidate_hash="1" * 64,
+        rule_spec=spec,
+        model_hash="2" * 64,
+        prompt_hash="3" * 64,
+        context_hash="4" * 64,
+        expires_at=datetime.now(UTC) - timedelta(seconds=1),
+    )
+    with pytest.raises(ValueError, match="expired"):
+        signer.verify(
+            signer.issue(claims),
+            actor_uid=actor,
+            source_text="手机号必须为11位数字",
+            rule_spec=spec,
+        )
+
+    valid = signer.issue(_receipt_claims(spec, actor))
+    with pytest.raises(ValueError, match="signature"):
+        signer.verify(
+            valid[:-1] + ("A" if valid[-1] != "A" else "B"),
+            actor_uid=actor,
+            source_text="手机号必须为11位数字",
+            rule_spec=spec,
+        )
+
+
+def test_rule_publication_requires_server_compile_and_test_evidence():
+    from app.core.data_rules.publication import (
+        GenerationReceiptSigner,
+        RulePublicationService,
+    )
+
+    repository = MemoryPublicationRepository()
+    signer = GenerationReceiptSigner("test-secret-with-sufficient-entropy")
+    service = RulePublicationService(
+        repository,
+        receipt_signer=signer,
+        compiler=DeterministicCompiler(),
+        test_runner=DeterministicDryRunner(),
+    )
+    spec = valid_rule_spec()
+    actor = new_governance_uid()
+    receipt = signer.issue(_receipt_claims(spec, actor))
+    draft = service.create_draft(
+        rule_spec=spec,
+        source_text="手机号必须为11位数字",
+        actor_uid=actor,
+        generation_receipt=receipt,
+        category="standard_clause",
+        source_language="zh-CN",
+        generated_kind="rulespec",
+    )
+    assert draft["status"] == "draft"
+
+    with pytest.raises(ValueError, match="compile and test evidence"):
+        service.publish(draft["id"], actor)
+
+    compiled = service.validate(draft["id"], actor)
+    assert compiled["plan_status"] == "compiled"
+    assert repository.compile_evidence["actor_uid"] == actor
+
+    tested = service.test(draft["id"], actor, plan_id=compiled["plan_id"])
+    assert tested["version_status"] == "validated"
+    assert tested["plan_status"] == "tested"
+
+    published = service.publish(draft["id"], actor)
+    assert published["status"] == "published"
+    assert published["plan_status"] == "published"
+    assert repository.published_by == actor
+
+
+def test_test_gate_rejects_forged_or_drifted_runner_evidence():
+    from app.core.data_rules.publication import RulePublicationService
+
+    class DriftedRunner:
+        def run(self, context):
+            return {
+                "status": "success",
+                "test_kind": "dry_run",
+                "run_id": new_governance_uid(),
+                "plan_hash": "0" * 64,
+                "schema_hashes": context["schema_hashes"],
+                "binding_hashes": context["binding_hashes"],
+                "counts": {},
+                "attestation": {"result_digest": "b" * 64},
+            }
+
+    repository = MemoryPublicationRepository()
+    repository.compile_evidence = {
+        "compiled": {"plan_hash": "f" * 64},
+    }
+    service = RulePublicationService(
+        repository,
+        receipt_signer=None,
+        compiler=DeterministicCompiler(),
+        test_runner=DriftedRunner(),
+    )
+    with pytest.raises(ValueError, match="exact compiled plan"):
+        service.test(
+            repository.version_id,
+            new_governance_uid(),
+            plan_id=repository.plan_id,
+        )
+    assert repository.test_evidence is None
+
+
+def test_physical_execution_plan_has_independent_test_and_publish_gate():
+    from app.core.data_rules.publication import PhysicalPlanPublicationService
+
+    class PhysicalRepository:
+        def __init__(self):
+            self.plan_id = new_governance_uid()
+            self.status = "compiled"
+
+        def load_physical_test_context(self, *, plan_id):
+            assert plan_id == self.plan_id
+            return {
+                "version_id": new_governance_uid(),
+                "version_status": "published",
+                "plan_id": plan_id,
+                "plan_status": self.status,
+                "plan_hash": "9" * 64,
+                "schema_hashes": {"input": "8" * 64},
+                "binding_hashes": {"input": "7" * 64},
+            }
+
+        def persist_physical_test(self, **kwargs):
+            self.status = "tested"
+            return {"plan_id": self.plan_id, "plan_status": "tested"}
+
+        def publish_tested_physical_plan(self, *, plan_id, actor_uid):
+            if self.status != "tested":
+                raise ValueError("physical compile and test evidence is required")
+            self.status = "published"
+            return {"plan_id": plan_id, "plan_status": "published"}
+
+    repository = PhysicalRepository()
+    service = PhysicalPlanPublicationService(
+        repository, test_runner=DeterministicDryRunner()
+    )
+    with pytest.raises(ValueError, match="physical compile and test evidence"):
+        service.publish(repository.plan_id, new_governance_uid())
+    tested = service.test(repository.plan_id, new_governance_uid())
+    assert tested["plan_status"] == "tested"
+    assert service.publish(
+        repository.plan_id, new_governance_uid()
+    )["plan_status"] == "published"

+ 152 - 0
tests/integration/test_data_rule_sql_execution.py

@@ -60,6 +60,21 @@ class DirectManager:
                 raise
 
 
+class ReadOnlyPreflightManager:
+    def __init__(self, engine):
+        self.engine = engine
+
+    @contextmanager
+    def connect(self, _uid, purpose):
+        assert purpose == "dataflow_read"
+        with self.engine.connect() as connection:
+            transaction = connection.begin()
+            try:
+                yield connection
+            finally:
+                transaction.rollback()
+
+
 class PlanRepository:
     def __init__(self, idempotency, context):
         self.idempotency = idempotency
@@ -133,6 +148,143 @@ def _snapshot(schema_ref):
     }
 
 
+@pytest.mark.parametrize(
+    ("dialect", "url", "schema_name", "collation", "regex_engine"), CASES
+)
+def test_server_owned_sql_preflight_explains_without_writing(
+    dialect, url, schema_name, collation, regex_engine
+):
+    from app.core.data_rules.compilers.sql import SqlGlotRuleCompiler
+    from app.core.data_rules.publication import (
+        ServerOwnedPhysicalPreflightRunner,
+    )
+
+    engine = create_engine(url, pool_pre_ping=True)
+    suffix = dialect.replace("postgresql", "pg")
+    source_name = f"task7_preflight_source_{suffix}"
+    target_name = f"task7_preflight_target_{suffix}"
+    capabilities = {
+        "dialect": dialect,
+        "timezone": "Asia/Shanghai",
+        "collation": collation,
+        "rounding_mode": "half_away_from_zero",
+        "regex_engine": regex_engine,
+    }
+    datasource_uid = new_governance_uid()
+    input_schema = _snapshot(f"bd:task7:{dialect}:raw")
+    output_schema = _snapshot(f"bd:task7:{dialect}:clean")
+    input_binding = {
+        "id": new_governance_uid(),
+        "data_source_uid": datasource_uid,
+        "object_kind": "table",
+        "object_ref": f"{schema_name}.{source_name}",
+        "schema_snapshot_id": input_schema["id"],
+        "access_mode": "read",
+        "dialect": dialect,
+        "write_mode": "append",
+    }
+    output_binding = {
+        "id": new_governance_uid(),
+        "data_source_uid": datasource_uid,
+        "object_kind": "table",
+        "object_ref": f"{schema_name}.{target_name}",
+        "schema_snapshot_id": output_schema["id"],
+        "access_mode": "write",
+        "dialect": dialect,
+        "write_mode": "append",
+    }
+    spec = validate_rule_spec(
+        {
+            "schema_version": "2.0",
+            "rule_uid": new_governance_uid(),
+            "name": f"task7_{dialect}_safe_preflight",
+            "input_schema_ref": input_schema["schema_ref"],
+            "output_schema_ref": output_schema["schema_ref"],
+            "steps": [
+                {
+                    "id": "trim_name",
+                    "op": "normalize_text",
+                    "column": "name",
+                    "trim": True,
+                }
+            ],
+            "null_policy": "explicit",
+            "timezone": "Asia/Shanghai",
+        }
+    )
+    rule = {
+        "id": new_governance_uid(),
+        "status": "published",
+        "rule_spec": spec,
+        "spec_hash": rule_spec_hash(spec),
+    }
+    try:
+        with engine.begin() as connection:
+            connection.execute(text(f"DROP TABLE IF EXISTS {target_name}"))
+            connection.execute(text(f"DROP TABLE IF EXISTS {source_name}"))
+            connection.execute(
+                text(
+                    f"CREATE TABLE {source_name} ("
+                    "customer_id BIGINT PRIMARY KEY, "
+                    "name VARCHAR(100), mobile VARCHAR(30))"
+                )
+            )
+            connection.execute(
+                text(
+                    f"CREATE TABLE {target_name} ("
+                    "customer_id BIGINT PRIMARY KEY, "
+                    "name VARCHAR(100), mobile VARCHAR(30))"
+                )
+            )
+            connection.execute(
+                text(
+                    f"INSERT INTO {source_name} "
+                    "(customer_id, name, mobile) "
+                    "VALUES (1, ' Alice ', '13800138000')"
+                )
+            )
+        compiled = SqlGlotRuleCompiler(dialect).compile(
+            rule_version=rule,
+            input_schema=input_schema,
+            output_schema=output_schema,
+            input_binding=input_binding,
+            output_binding=output_binding,
+            backend=capabilities,
+        )
+        schema_hashes = {
+            "input_schema_hash": input_schema["schema_hash"],
+            "output_schema_hash": output_schema["schema_hash"],
+        }
+        binding_hashes = {
+            "input": "a" * 64,
+            "output": "b" * 64,
+        }
+        result = ServerOwnedPhysicalPreflightRunner(
+            artifact_store=None,
+            datasource_manager=ReadOnlyPreflightManager(engine),
+        ).run(
+            {
+                "backend": "sql_pushdown",
+                "plan": compiled["plan"],
+                "plan_hash": compiled["plan_hash"],
+                "schema_hashes": schema_hashes,
+                "binding_hashes": binding_hashes,
+            }
+        )
+        assert result["status"] == "success"
+        assert result["plan_hash"] == compiled["plan_hash"]
+        assert result["attestation"]["dialect"] == dialect
+        with engine.connect() as connection:
+            assert connection.execute(
+                text(f"SELECT COUNT(*) FROM {target_name}")
+            ).scalar_one() == 0
+    finally:
+        with engine.begin() as connection:
+            connection.execute(text(f"DROP TABLE IF EXISTS {target_name}"))
+            connection.execute(text(f"DROP TABLE IF EXISTS {source_name}"))
+        engine.dispose()
+
+
 @pytest.mark.parametrize(
     ("dialect", "url", "schema_name", "collation", "regex_engine"), CASES
 )

+ 261 - 0
tests/integration/test_rule_publication_lifecycle.py

@@ -0,0 +1,261 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from datetime import UTC, datetime, timedelta
+
+import polars as pl
+import pytest
+from minio import Minio
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import Session
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_rules.contracts import rule_spec_hash
+from app.core.data_rules.execution_contracts import canonical_schema_hash
+from app.core.data_rules.publication import (
+    GenerationReceiptSigner,
+    LogicalRuleCompiler,
+    RulePublicationService,
+    ServerOwnedLogicalDryRunRunner,
+    ServerOwnedPhysicalPreflightRunner,
+    generation_receipt_claims,
+)
+from app.core.data_rules.repository import DataRuleRepository
+from app.runner.artifacts import ArtifactStore
+from tests.core.data_rules.test_contracts import valid_rule_spec
+from tests.integration.test_data_rule_polars_execution import _compose_value
+
+pytestmark = pytest.mark.integration
+
+
+def _hash(value):
+    return hashlib.sha256(
+        json.dumps(
+            value,
+            sort_keys=True,
+            separators=(",", ":"),
+            ensure_ascii=False,
+        ).encode("utf-8")
+    ).hexdigest()
+
+
+@pytest.fixture()
+def database_url():
+    value = os.environ.get("TEST_DATABASE_URL")
+    if not value:
+        pytest.skip("TEST_DATABASE_URL is not configured")
+    return value
+
+
+def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
+    minio_user = _compose_value(r"MINIO_ROOT_USER:\s*([^\s]+)")
+    minio_password = _compose_value(r"MINIO_ROOT_PASSWORD:\s*([^\s]+)")
+    minio_port = _compose_value(r'"(19000):9000"')
+    bucket = _compose_value(r"mc mb --ignore-existing local/([^\s]+)")
+    store = ArtifactStore(
+        Minio(
+            f"127.0.0.1:{minio_port}",
+            access_key=minio_user,
+            secret_key=minio_password,
+            secure=False,
+        ),
+        bucket=bucket,
+        max_artifact_bytes=32 * 1024 * 1024,
+        max_rows=100_000,
+        memory_limit_bytes=256 * 1024 * 1024,
+        max_ttl_seconds=3600,
+    )
+    fields = [
+        {"name": "name", "type": "string", "nullable": True},
+        {"name": "mobile", "type": "string", "nullable": True},
+    ]
+    sample = store.write(
+        pl.DataFrame(
+            {
+                "name": [" Alice ", " Bob ", " Carol "],
+                "mobile": ["13800138000", "invalid", "13900139000"],
+            }
+        ),
+        new_governance_uid(),
+        600,
+        schema_fields=fields,
+    )
+    engine = create_engine(database_url)
+    with engine.connect() as connection:
+        transaction = connection.begin()
+        try:
+            actor = connection.execute(
+                text(
+                    "SELECT id::text FROM public.users "
+                    "WHERE status = 'active' ORDER BY created_at LIMIT 1"
+                )
+            ).scalar_one()
+            session = Session(bind=connection)
+            repository = DataRuleRepository(session)
+            snapshot_value = {
+                    "schema_ref": "bd:rule-publication:integration",
+                    "source_revision": "integration:1",
+                    "fields": fields,
+                }
+            snapshot_value["schema_hash"] = canonical_schema_hash(
+                snapshot_value["fields"]
+            )
+            snapshot = repository.persist_schema_snapshot(
+                snapshot=snapshot_value
+            )
+            validation_context = {
+                "schema_snapshot_id": snapshot["id"],
+                "schema_hash": snapshot["schema_hash"],
+                "fields": snapshot["fields"],
+                "sample_artifact_ref": sample["artifact_ref"],
+                "sample_artifact_digest": sample["digest"],
+            }
+            spec = valid_rule_spec()
+            spec["input_schema_ref"] = snapshot["schema_ref"]
+            spec["output_schema_ref"] = snapshot["schema_ref"]
+            spec["steps"][1]["on_failure"] = "quarantine"
+            candidate = {
+                "schema_version": "1.0",
+                "candidate_type": "rule",
+                "rule_spec": spec,
+                "standard_spec": None,
+                "assumptions": [],
+                "ambiguities": [],
+                "confidence": 0.99,
+                "explanation": "integration candidate",
+            }
+            evidence = {
+                "status": "ready",
+                "source_text": "手机号去空格后必须为11位数字",
+                "authoring_surface": "data_standard",
+                "candidate": candidate,
+                "model_provider": "integration",
+                "model_name": "closed-fixture",
+                "prompt_version": "integration-v1",
+                "schema_version": "1.0",
+                "context_hash": _hash(validation_context),
+                "candidate_hash": repository.candidate_hash(candidate),
+                "model_hash": "a" * 64,
+                "prompt_hash": "b" * 64,
+                "repair_attempts": 0,
+                "generation_attempts": [],
+            }
+            generation = repository.record_generation_run(
+                evidence=evidence,
+                created_by=actor,
+                validation_context=validation_context,
+            )
+            signer = GenerationReceiptSigner(
+                "integration-receipt-secret-with-entropy"
+            )
+            claims = generation_receipt_claims(
+                generation_run_id=generation["id"],
+                actor_uid=actor,
+                source_text=evidence["source_text"],
+                candidate_hash=evidence["candidate_hash"],
+                rule_spec=spec,
+                model_hash=evidence["model_hash"],
+                prompt_hash=evidence["prompt_hash"],
+                context_hash=evidence["context_hash"],
+                expires_at=datetime.now(UTC) + timedelta(minutes=5),
+            )
+            receipt = signer.issue(claims)
+            service = RulePublicationService(
+                repository,
+                receipt_signer=signer,
+                compiler=LogicalRuleCompiler(),
+                test_runner=ServerOwnedLogicalDryRunRunner(store),
+            )
+            draft = service.create_draft(
+                rule_spec=spec,
+                source_text=evidence["source_text"],
+                actor_uid=actor,
+                generation_receipt=receipt,
+                category="standard_clause",
+                source_language="zh-CN",
+                generated_kind="rulespec",
+            )
+            assert draft["status"] == "draft"
+            assert draft["spec_hash"] == rule_spec_hash(spec)
+            with pytest.raises(ValueError, match="consumable"):
+                service.create_draft(
+                    rule_spec=spec,
+                    source_text=evidence["source_text"],
+                    actor_uid=actor,
+                    generation_receipt=receipt,
+                    category="standard_clause",
+                    source_language="zh-CN",
+                    generated_kind="rulespec",
+                )
+
+            compiled = service.validate(draft["id"], actor)
+            assert compiled["plan_status"] == "compiled"
+            logical_plan = (
+                connection.execute(
+                    text(
+                        "SELECT plan, plan_hash, schema_hashes "
+                        "FROM public.rule_logical_plans "
+                        "WHERE id = CAST(:id AS uuid)"
+                    ),
+                    {"id": compiled["plan_id"]},
+                )
+                .mappings()
+                .one()
+            )
+            physical_result = ServerOwnedPhysicalPreflightRunner(store).run(
+                {
+                    "backend": "polars_batch",
+                    "plan": logical_plan["plan"],
+                    "plan_hash": logical_plan["plan_hash"],
+                    "schema_hashes": logical_plan["schema_hashes"],
+                    "binding_hashes": {},
+                    "sample_artifact": {
+                        "artifact_ref": sample["artifact_ref"],
+                        "digest": sample["digest"],
+                        "schema_fields": fields,
+                    },
+                }
+            )
+            assert physical_result["counts"]["rows_quarantined"] == 1
+            tampered_sample = repository.load_logical_test_context(
+                version_id=draft["id"],
+                plan_id=compiled["plan_id"],
+            )
+            tampered_sample["sample_artifact_digest"] = "f" * 64
+            with pytest.raises(ValueError, match="drifted"):
+                ServerOwnedLogicalDryRunRunner(store).run(tampered_sample)
+            tested = service.test(
+                draft["id"], actor, plan_id=compiled["plan_id"]
+            )
+            assert tested["version_status"] == "validated"
+            assert tested["test_evidence"]["counts"]["rows_quarantined"] == 1
+            assert tested["test_evidence"]["counts"]["rows_rejected"] == 0
+            published = service.publish(draft["id"], actor)
+            assert published["status"] == "published"
+            assert published["plan_status"] == "published"
+            assert service.publish(draft["id"], actor) == published
+            assert service.catalog(query=spec["name"], limit=10)[0][
+                "id"
+            ] == draft["id"]
+
+            rows = connection.execute(
+                text(
+                    "SELECT rv.status, lp.status, "
+                    "(SELECT COUNT(*) FROM public.rule_logical_compile_evidence "
+                    "WHERE logical_plan_id = lp.id) AS compile_count, "
+                    "(SELECT COUNT(*) FROM public.rule_logical_test_evidence "
+                    "WHERE logical_plan_id = lp.id) AS test_count "
+                    "FROM public.data_rule_versions rv "
+                    "JOIN public.rule_logical_plans lp "
+                    "ON lp.rule_version_id = rv.id "
+                    "WHERE rv.id = CAST(:id AS uuid)"
+                ),
+                {"id": draft["id"]},
+            ).one()
+            assert tuple(rows) == ("published", "published", 1, 1)
+        finally:
+            transaction.rollback()
+            engine.dispose()
+            store.delete(sample["artifact_ref"])

+ 138 - 6
tests/test_data_rule_api.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
 
 from app.core.common.identifiers import new_governance_uid
 from app.core.data_rules.contracts import rule_spec_hash
@@ -44,7 +44,7 @@ class FakeRuleRepository:
             "id": new_governance_uid(),
             "rule_uid": kwargs["rule_spec"]["rule_uid"],
             "version_no": 1,
-            "status": "validated",
+            "status": "draft",
             "spec_hash": rule_spec_hash(kwargs["rule_spec"]),
             "created": True,
         }
@@ -57,6 +57,16 @@ class FakeRuleRepository:
             "decision": kwargs["evidence"]["status"],
         }
 
+    def resolve_validation_context(self, context):
+        self.calls.append(("resolve_validation_context", {"context": context}))
+        return {
+            "schema_snapshot_id": new_governance_uid(),
+            "schema_hash": "c" * 64,
+            "fields": [{"name": "mobile", "type": "string"}],
+            "sample_artifact_ref": None,
+            "sample_artifact_digest": None,
+        }
+
     def publish_rule_version(self, **kwargs):
         self.calls.append(("publish_rule_version", kwargs))
         return {
@@ -108,6 +118,74 @@ class FakeReleaseService:
         }
 
 
+class FakePublicationService:
+    def __init__(self, repository):
+        self.repository = repository
+        self.plan_id = new_governance_uid()
+        self.version_id = None
+
+    def create_draft(self, **kwargs):
+        self.repository.calls.append(("create_rule_version", kwargs))
+        self.version_id = new_governance_uid()
+        return {
+            "id": self.version_id,
+            "rule_uid": kwargs["rule_spec"]["rule_uid"],
+            "version_no": 1,
+            "status": "draft",
+            "spec_hash": rule_spec_hash(kwargs["rule_spec"]),
+            "generation_run_id": new_governance_uid(),
+            "created": True,
+        }
+
+    def validate(self, version_id, actor_uid):
+        self.repository.calls.append(
+            ("validate_rule_version", {"version_id": version_id, "actor_uid": actor_uid})
+        )
+        return {
+            "version_id": version_id,
+            "version_status": "draft",
+            "plan_id": self.plan_id,
+            "plan_status": "compiled",
+            "plan_hash": "d" * 64,
+        }
+
+    def test(self, version_id, actor_uid, *, plan_id):
+        self.repository.calls.append(
+            (
+                "test_rule_version",
+                {
+                    "version_id": version_id,
+                    "actor_uid": actor_uid,
+                    "plan_id": plan_id,
+                },
+            )
+        )
+        return {
+            "version_id": version_id,
+            "version_status": "validated",
+            "plan_id": plan_id,
+            "plan_status": "tested",
+            "plan_hash": "d" * 64,
+        }
+
+    def publish(self, version_id, actor_uid):
+        self.repository.calls.append(
+            ("publish_rule_version", {"version_id": version_id, "actor_uid": actor_uid})
+        )
+        return {
+            "id": version_id,
+            "status": "published",
+            "plan_id": self.plan_id,
+            "plan_status": "published",
+        }
+
+    def evidence(self, version_id):
+        return {"version_id": version_id, "version_status": "validated"}
+
+    def catalog(self, *, query, limit):
+        return []
+
+
 class FakeGraphSession:
     def __init__(self):
         self.calls = []
@@ -161,7 +239,7 @@ def _headers(app, role):
         user_id=new_governance_uid(),
         roles=[role],
         secret=app.config["SECRET_KEY"],
-        now=datetime.now(timezone.utc),
+        now=datetime.now(UTC),
         lifetime=timedelta(minutes=10),
     )
     return {"Authorization": f"Bearer {token}"}
@@ -244,8 +322,10 @@ def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch)
     assert response.status_code == 200
     assert response.get_json()["data"]["status"] == "ready"
     assert response.get_json()["data"]["generation_run_id"]
+    assert response.get_json()["data"]["generation_receipt"]
     assert agent.calls[0]["authoring_surface"] == "data_standard"
-    assert repository.calls[0][0] == "record_generation_run"
+    assert repository.calls[0][0] == "resolve_validation_context"
+    assert repository.calls[1][0] == "record_generation_run"
 
 
 def test_production_line_resolve_preview_expands_standard_without_writing(monkeypatch):
@@ -317,6 +397,8 @@ def test_rule_and_standard_versions_are_created_then_published_by_separate_roles
     app.config["TESTING"] = True
     repository = FakeRuleRepository()
     app.extensions["data_rule_repository"] = repository
+    publication = FakePublicationService(repository)
+    app.extensions["rule_publication_service"] = publication
     client = app.test_client()
     rule_spec = valid_rule_spec()
 
@@ -326,13 +408,28 @@ def test_rule_and_standard_versions_are_created_then_published_by_separate_roles
             "source_text": "手机号必须为11位数字",
             "rule_spec": rule_spec,
             "category": "standard_clause",
+            "generation_receipt": "signed-test-receipt",
         },
         headers=_headers(app, "editor"),
     )
     assert created.status_code == 201
-    assert created.get_json()["data"]["status"] == "validated"
+    assert created.get_json()["data"]["status"] == "draft"
     rule_version_id = created.get_json()["data"]["id"]
 
+    compiled = client.post(
+        f"/api/rules/rule-versions/{rule_version_id}/validate",
+        headers=_headers(app, "editor"),
+    )
+    assert compiled.status_code == 200
+    plan_id = compiled.get_json()["data"]["plan_id"]
+    tested = client.post(
+        f"/api/rules/rule-versions/{rule_version_id}/test",
+        json={"plan_id": plan_id},
+        headers=_headers(app, "editor"),
+    )
+    assert tested.status_code == 200
+    assert tested.get_json()["data"]["version_status"] == "validated"
+
     forbidden = client.post(
         f"/api/rules/rule-versions/{rule_version_id}/publish",
         headers=_headers(app, "editor"),
@@ -367,6 +464,8 @@ def test_rule_and_standard_versions_are_created_then_published_by_separate_roles
     methods = [method for method, _kwargs in repository.calls]
     assert methods == [
         "create_rule_version",
+        "validate_rule_version",
+        "test_rule_version",
         "publish_rule_version",
         "create_standard_version",
         "publish_standard_version",
@@ -379,7 +478,9 @@ def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
     app = create_app()
     _use_token_identity(monkeypatch)
     app.config["TESTING"] = True
-    app.extensions["data_rule_repository"] = FakeRuleRepository()
+    repository = FakeRuleRepository()
+    app.extensions["data_rule_repository"] = repository
+    app.extensions["rule_publication_service"] = FakePublicationService(repository)
     client = app.test_client()
 
     response = client.post(
@@ -387,6 +488,7 @@ def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
         json={
             "source_text": "手机号必须为11位数字",
             "rule_spec": valid_rule_spec(),
+            "generation_receipt": "signed-test-receipt",
             "status": "published",
         },
         headers=_headers(app, "editor"),
@@ -395,6 +497,36 @@ def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
     assert response.status_code == 400
 
 
+def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(monkeypatch):
+    from app import create_app
+
+    app = create_app()
+    _use_token_identity(monkeypatch)
+    app.config["TESTING"] = True
+    repository = FakeRuleRepository()
+    app.extensions["rule_publication_service"] = FakePublicationService(repository)
+    client = app.test_client()
+    version_id = new_governance_uid()
+
+    forged_compile = client.post(
+        f"/api/rules/rule-versions/{version_id}/validate",
+        json={"status": "success", "plan_hash": "a" * 64},
+        headers=_headers(app, "editor"),
+    )
+    forged_test = client.post(
+        f"/api/rules/rule-versions/{version_id}/test",
+        json={
+            "plan_id": new_governance_uid(),
+            "evidence": {"status": "success"},
+        },
+        headers=_headers(app, "editor"),
+    )
+
+    assert forged_compile.status_code == 409
+    assert forged_test.status_code == 409
+    assert repository.calls == []
+
+
 def test_create_rule_version_rejects_legacy_v1_payload_before_repository(monkeypatch):
     from app import create_app
 

Некоторые файлы не были показаны из-за большого количества измененных файлов