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

fix: close rule publication trust gaps

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

+ 58 - 26
.superpowers/sdd/task-7-report.md

@@ -12,8 +12,9 @@ state.
 
 The delivered chain is:
 
-1. `/interpret` resolves a server-owned schema snapshot and optional
-   server-owned sample artifact before invoking the authoring model.
+1. `/interpret` resolves distinct server-owned input/output schema snapshots,
+   a required input sample, and an optional golden output 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.
@@ -23,7 +24,7 @@ The delivered chain is:
    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.
+   the separately pinned input and output schema snapshots.
 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`.
@@ -54,15 +55,25 @@ boundary.
 - validation context hash;
 - issued-at and expiry timestamps.
 
+Receipts use the dedicated required `RULE_GENERATION_RECEIPT_SECRET` and never
+fall back to the Flask session secret. Issued-at, expiry, maximum lifetime, and
+clock skew are checked cryptographically and against current database time
+during consumption.
+
 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.
+Publication locks the rule/plan/profile/schema or
+plan/component/rule/binding/schema rows in one transaction and re-reads the
+current compiler, RuleSpec, plan, schema, binding, data source, object,
+dialect, evidence, and audit chain. Same-actor retries after an unknown commit
+outcome return the canonical published result; a different actor cannot use
+that replay path. Real PostgreSQL concurrency tests use independent threads,
+connections, and sessions: same-actor contenders return one canonical result
+and one audit, while a different actor is rejected after the winning commit.
 
 ## AI repair boundary
 
@@ -74,6 +85,11 @@ Each attempt persists:
 - deterministic error code;
 - model, prompt, and context hashes.
 
+The prompt hash is calculated from the actual full prompt sent for that
+attempt, including the output JSON schema and accumulated repair messages.
+The final generation receipt therefore binds the final repair prompt, not the
+initial prompt.
+
 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
@@ -91,6 +107,11 @@ binds the exact trusted run ID and server-generated attestation:
 - violation digest;
 - bounded row and violation counts.
 
+All four evidence writers use `INSERT ... RETURNING`. On a uniqueness conflict
+they load the canonical database row, compare the complete security-relevant
+content, and return the real existing evidence ID; divergent replay fails
+closed.
+
 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
@@ -107,6 +128,11 @@ 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.
 
+Logical dry-run supports a distinct output schema and optional golden Parquet
+result. Golden comparison uses canonical output-field order. The real
+cross-schema acceptance reads `name/mobile`, derives `name_copy`, removes one
+quarantined row, and compares the output to the pinned golden artifact.
+
 ## Forward-only migration
 
 Migration `20260723_200` is forward-only and adds:
@@ -122,9 +148,12 @@ Migration `20260723_200` is forward-only and adds:
 - 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.
+The migration refuses to run when revision 190 contains any published
+RuleVersion or execution plan, because pre-Task-7 rows cannot be silently
+upgraded into trusted evidence. Physical evidence carries an explicit
+`legacy_untrusted` marker, and publication/Runner/DataFlow gates require it to
+be false. Downgrade is deliberately rejected because deleting publication and
+generation evidence would break audit and replay guarantees.
 
 ## API surface
 
@@ -141,9 +170,11 @@ The following server-owned operations are available:
 - `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.
+Request bodies and catalog query parameters use closed shapes. Compile/test
+endpoints do not accept caller evidence. Editor permissions cover
+validation/testing while publication keeps the existing administrative
+permission boundary. The documented canonical catalog path is implemented;
+the prior nested path remains a compatibility alias.
 
 ## Acceptance evidence
 
@@ -160,19 +191,19 @@ Fail-first evidence included:
 
 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:
+- focused data-rule/API/migration suite: `183 passed`;
+- Runner plus real PostgreSQL/MySQL/Polars regression: `126 passed`;
+- real PostgreSQL + MinIO cross-schema receipt/logical 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`;
+- real `PhysicalPlanPublicationService` PostgreSQL/MySQL lifecycle, drift,
+  evidence replay, and PostgreSQL concurrency: `2 passed`;
 - full repository suite:
-  `616 passed, 29 skipped, 59 subtests passed`;
+  `626 passed, 29 skipped, 59 subtests passed`;
 - changed-file Ruff: `All checks passed!`;
 - `git diff --check`: passed;
-- local PostgreSQL Alembic current/upgrade:
-  `20260723_200 (head)`.
+- empty local PostgreSQL full Alembic rebuild:
+  base through `20260723_200 (head)`;
+- real revision-190 legacy publication upgrade guard: `1 passed`.
 
 Skipped tests are environment-gated integration suites. The required real
 PostgreSQL, MySQL, and MinIO acceptance tests above were run explicitly against
@@ -186,8 +217,9 @@ 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.
+Task 7 now supports separate input/output snapshots and cross-schema
+transformations. The remaining scope is production operational rollout:
+secret provisioning/rotation, multi-replica load and failure testing,
+monitoring/alert thresholds, and production canary/rollback evidence. Those
+operational gates are outside this local Docker acceptance and must be closed
+before a production environment is declared ready.

+ 13 - 3
app/api/data_rules/routes.py

@@ -90,7 +90,12 @@ 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"])
+    secret = current_app.config.get("RULE_GENERATION_RECEIPT_SECRET")
+    if not isinstance(secret, str) or not secret.strip():
+        raise RuntimeError(
+            "dedicated generation receipt secret is not configured"
+        )
+    signer = GenerationReceiptSigner(secret)
     current_app.extensions["generation_receipt_signer"] = signer
     return signer
 
@@ -203,7 +208,7 @@ def capabilities():
 @bp.post("/validate")
 def validate_asset():
     try:
-        body = _body()
+        body = _closed_body({"asset_type", "spec"})
         asset_type = body.get("asset_type")
         if asset_type not in _VALIDATORS:
             raise ValueError("unsupported asset type")
@@ -239,7 +244,9 @@ def _authoring_agent() -> RuleAuthoringAgent:
 @bp.post("/interpret")
 def interpret_rule():
     try:
-        body = _body()
+        body = _closed_body(
+            {"source_text", "authoring_surface", "context"}
+        )
         repository = _repository()
         validation_context = repository.resolve_validation_context(
             body.get("context", {})
@@ -411,9 +418,12 @@ def rule_version_evidence(version_id: str):
         return jsonify(failed("规则验证服务未配置", code=503)), 503
 
 
+@bp.get("/catalog")
 @bp.get("/catalog/rule-versions")
 def published_rule_catalog():
     try:
+        if set(request.args) - {"query", "limit"}:
+            raise ValueError("catalog query contains unsupported fields")
         query = request.args.get("query", "")
         limit = int(request.args.get("limit", "50"))
         return jsonify(

+ 5 - 4
app/config/config.py

@@ -1,6 +1,6 @@
 import os
 import platform
-from typing import Mapping
+from collections.abc import Mapping
 
 
 def get_bool_env(name: str, default: bool = False) -> bool:
@@ -144,9 +144,7 @@ def is_placeholder_env_value(value: str) -> bool:
         return True
     if "dataops_user@" in lower and "127.0.0.1" in lower:
         return True
-    if lower in {"127.0.0.1:9000", "localhost:9000"}:
-        return True
-    return False
+    return lower in {"127.0.0.1:9000", "localhost:9000"}
 
 
 def _is_local_minio_host(host: str) -> bool:
@@ -364,6 +362,9 @@ class BaseConfig:
     """基础配置类,包含所有环境共享的配置"""
 
     SECRET_KEY = os.environ.get("SECRET_KEY") or "you-will-never-guess"
+    RULE_GENERATION_RECEIPT_SECRET = os.environ.get(
+        "RULE_GENERATION_RECEIPT_SECRET"
+    )
     JSON_AS_ASCII = False
     JSONIFY_PRETTYPRINT_REGULAR = True
     JSON_SORT_KEYS = False

+ 21 - 10
app/core/data_rules/authoring.py

@@ -94,6 +94,20 @@ def build_rule_messages(
     ]
 
 
+def effective_rule_messages(
+    messages: list[dict[str, str]],
+    response_schema: dict[str, Any],
+) -> list[dict[str, str]]:
+    return [
+        *messages,
+        {
+            "role": "user",
+            "content": "OUTPUT_JSON_SCHEMA:\n"
+            + _canonical(response_schema),
+        },
+    ]
+
+
 class OpenAICompatibleRuleModel:
     """Use the repository's OpenAI-compatible client with deterministic output."""
 
@@ -124,14 +138,9 @@ class OpenAICompatibleRuleModel:
             model=self.model_name,
             messages=cast(
                 Any,
-                [
-                    *list(messages),
-                    {
-                        "role": "user",
-                        "content": "OUTPUT_JSON_SCHEMA:\n"
-                        + _canonical(response_schema),
-                    },
-                ],
+                effective_rule_messages(
+                    list(messages), response_schema
+                ),
             ),
             stream=False,
             temperature=0,
@@ -203,11 +212,13 @@ class RuleAuthoringAgent:
             raise ValueError("authoring context exceeds 65536 bytes")
 
         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):
+            prompt_hash = _hash(
+                effective_rule_messages(messages, RULE_CANDIDATE_SCHEMA)
+            )
             raw = self.model.generate(
                 messages=messages,
                 response_schema=RULE_CANDIDATE_SCHEMA,
@@ -316,7 +327,7 @@ class RuleAuthoringAgent:
             "context_hash": _hash(context),
             "candidate_hash": _hash(candidate),
             "model_hash": model_hash,
-            "prompt_hash": prompt_hash,
+            "prompt_hash": attempts[-1]["prompt_hash"],
             "repair_attempts": len(attempts) - 1,
             "generation_attempts": attempts,
         }

+ 132 - 33
app/core/data_rules/publication.py

@@ -28,6 +28,7 @@ _RECEIPT_KEYS = {
     "model_hash",
     "prompt_hash",
     "context_hash",
+    "issued_at",
     "expires_at",
 }
 _COMPILED_KEYS = {
@@ -123,10 +124,14 @@ def generation_receipt_claims(
     model_hash: str,
     prompt_hash: str,
     context_hash: str,
+    issued_at: datetime | None = None,
     expires_at: datetime,
 ) -> dict[str, Any]:
     """Build the closed, non-secret claim set signed by ``/interpret``."""
 
+    issued = issued_at or datetime.now(UTC)
+    if not isinstance(issued, datetime) or issued.tzinfo is None:
+        raise ValueError("generation receipt issue time must be timezone aware")
     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)
@@ -142,6 +147,7 @@ def generation_receipt_claims(
         "model_hash": _digest(model_hash, "model_hash"),
         "prompt_hash": _digest(prompt_hash, "prompt_hash"),
         "context_hash": _digest(context_hash, "context_hash"),
+        "issued_at": int(issued.timestamp()),
         "expires_at": int(expires_at.timestamp()),
     }
 
@@ -149,16 +155,41 @@ def generation_receipt_claims(
 class GenerationReceiptSigner:
     """Issue and verify compact HMAC receipts without exposing the secret."""
 
-    def __init__(self, secret: str | bytes):
+    def __init__(
+        self,
+        secret: str | bytes,
+        *,
+        clock=None,
+        max_ttl_seconds: int = 600,
+        clock_skew_seconds: int = 30,
+    ):
         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")
+        if (
+            isinstance(max_ttl_seconds, bool)
+            or not isinstance(max_ttl_seconds, int)
+            or max_ttl_seconds < 1
+            or max_ttl_seconds > 3600
+        ):
+            raise ValueError("generation receipt max TTL is invalid")
+        if (
+            isinstance(clock_skew_seconds, bool)
+            or not isinstance(clock_skew_seconds, int)
+            or clock_skew_seconds < 0
+            or clock_skew_seconds > 300
+        ):
+            raise ValueError("generation receipt clock skew is invalid")
         self._secret = secret_bytes
+        self._clock = clock or (lambda: datetime.now(UTC))
+        self.max_ttl_seconds = max_ttl_seconds
+        self.clock_skew_seconds = clock_skew_seconds
 
     def issue(self, claims: dict[str, Any]) -> str:
         normalized = self._validate_claims(claims)
+        self._validate_time(normalized, self._clock())
         payload = _b64encode(_canonical(normalized))
         signature = hmac.new(
             self._secret, payload.encode("ascii"), hashlib.sha256
@@ -191,11 +222,10 @@ class GenerationReceiptSigner:
         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)
+        current = now or self._clock()
         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")
+        self._validate_time(claims, current)
         if claims["actor_uid"] != _uid(actor_uid, "actor_uid"):
             raise ValueError("generation receipt actor does not match")
         if claims["source_text_hash"] != _hash_text(
@@ -208,19 +238,42 @@ class GenerationReceiptSigner:
             raise ValueError("generation receipt RuleSpec does not match")
         return claims
 
+    def _validate_time(
+        self, claims: dict[str, Any], current: datetime
+    ) -> None:
+        if not isinstance(current, datetime) or current.tzinfo is None:
+            raise ValueError("generation receipt clock must be timezone aware")
+        now = int(current.timestamp())
+        if now >= claims["expires_at"]:
+            raise ValueError("generation receipt has expired")
+        if claims["issued_at"] > now + self.clock_skew_seconds:
+            raise ValueError("generation receipt issue time is in the future")
+        if claims["expires_at"] <= claims["issued_at"]:
+            raise ValueError("generation receipt expiry is invalid")
+        if (
+            claims["expires_at"] - claims["issued_at"]
+            > self.max_ttl_seconds
+        ):
+            raise ValueError("generation receipt TTL exceeds the maximum")
+
     @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")
+        temporal = {}
+        for key in ("issued_at", "expires_at"):
+            item = value.get(key)
+            if (
+                isinstance(item, bool)
+                or not isinstance(item, int)
+                or item <= 0
+            ):
+                raise ValueError(
+                    f"generation receipt {key} is invalid"
+                )
+            temporal[key] = item
         return {
             "version": "1",
             "generation_run_id": _uid(
@@ -241,7 +294,7 @@ class GenerationReceiptSigner:
             "context_hash": _digest(
                 value.get("context_hash"), "context_hash"
             ),
-            "expires_at": expires_at,
+            **temporal,
         }
 
 
@@ -530,41 +583,62 @@ class LogicalRuleCompiler:
         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 ""),
-        }
+        snapshots = {}
+        for prefix in ("input", "output"):
+            fields = profile.get(f"{prefix}_fields")
+            if not isinstance(fields, list) or not fields:
+                raise ValueError(
+                    f"trusted {prefix} validation fields are unavailable"
+                )
+            snapshots[prefix] = {
+                "id": _uid(
+                    profile.get(f"{prefix}_schema_snapshot_id"),
+                    f"{prefix}_schema_snapshot_id",
+                ),
+                "schema_ref": str(
+                    profile.get(f"{prefix}_schema_ref") or ""
+                ),
+                "schema_hash": _digest(
+                    profile.get(f"{prefix}_schema_hash"),
+                    f"{prefix}_schema_hash",
+                ),
+                "fields": copy.deepcopy(fields),
+                "source_revision": str(
+                    profile.get(f"{prefix}_source_revision") or ""
+                ),
+            }
+        if (
+            spec["input_schema_ref"] != snapshots["input"]["schema_ref"]
+            or spec["output_schema_ref"]
+            != snapshots["output"]["schema_ref"]
+        ):
+            raise ValueError(
+                "RuleSpec schema references do not match validation profile"
+            )
         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_schema=snapshots["input"],
+            output_schema=snapshots["output"],
             input_binding={
                 "id": profile_id,
                 **binding_base,
+                "schema_snapshot_id": snapshots["input"]["id"],
+                "access_mode": "read",
                 "object_ref": "validation-input",
             },
             output_binding={
                 "id": version_id,
                 **binding_base,
+                "schema_snapshot_id": snapshots["output"]["id"],
+                "access_mode": "write",
                 "object_ref": "validation-output",
             },
             backend={
@@ -584,7 +658,10 @@ class LogicalRuleCompiler:
             "compiler_version": compiled["compiler_version"],
             "plan": plan,
             "plan_hash": compiled["plan_hash"],
-            "schema_hashes": {"input": schema_hash},
+            "schema_hashes": {
+                "input": snapshots["input"]["schema_hash"],
+                "output": snapshots["output"]["schema_hash"],
+            },
             "binding_hashes": {},
             "capabilities": {
                 "supported_execution_backends": ["polars"],
@@ -606,12 +683,12 @@ class ServerOwnedLogicalDryRunRunner:
         )
         from app.runner.polars_worker import execute_isolated_polars_plan
 
-        sample_ref = context.get("sample_artifact_ref")
+        sample_ref = context.get("input_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",
+            context.get("input_sample_artifact_digest"),
+            "input_sample_artifact_digest",
         )
         plan = validate_bound_polars_plan(context.get("plan"))
         described = self.artifact_store.describe(sample_ref)
@@ -649,6 +726,27 @@ class ServerOwnedLogicalDryRunRunner:
                         "memory_limit_bytes"
                     ],
                 )
+            golden_ref = context.get("golden_output_artifact_ref")
+            golden_digest = context.get("golden_output_artifact_digest")
+            if golden_ref is not None:
+                import polars as pl
+
+                expected = self.artifact_store.read(
+                    golden_ref,
+                    _digest(
+                        golden_digest,
+                        "golden_output_artifact_digest",
+                    ),
+                    expected_schema_fields=plan["output_fields"],
+                    limits=plan["resource_limits"],
+                ).select(
+                    [field["name"] for field in plan["output_fields"]]
+                ).collect()
+                actual = pl.read_parquet(output_path)
+                if not actual.equals(expected, null_equal=True):
+                    raise ValueError(
+                        "logical dry-run does not match golden output"
+                    )
             prepared = self.artifact_store.prepare_path(
                 output_path,
                 new_governance_uid(),
@@ -686,6 +784,7 @@ class ServerOwnedLogicalDryRunRunner:
                 "violation_digest": hashlib.sha256(
                     _canonical(result.get("violations", []))
                 ).hexdigest(),
+                "golden_output_digest": golden_digest,
             },
         }
 

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


+ 103 - 1
app/runner/rules.py

@@ -25,6 +25,7 @@ from app.core.data_rules.compilers.sql import (
     COMPILER_VERSION as SQL_COMPILER_VERSION,
 )
 from app.core.data_rules.compilers.sql import (
+    bound_sql_plan_relations,
     validate_bound_sql_plan,
 )
 from app.runner.nodes import NodeExecutionError
@@ -94,10 +95,79 @@ class PostgresRulePlanRepository:
                 r.spec_hash AS canonical_rule_spec_hash,
                 ins.id::text AS canonical_input_schema_snapshot_id,
                 ins.schema_hash AS canonical_input_schema_hash,
+                ib.binding_hash AS canonical_input_binding_hash,
+                ib.data_source_uid::text AS canonical_input_data_source_uid,
+                ib.object_kind AS canonical_input_object_kind,
+                ib.object_ref AS canonical_input_object_ref,
+                ib.dialect AS canonical_input_dialect,
                 outs.id::text AS canonical_output_schema_snapshot_id,
                 outs.schema_hash AS canonical_output_schema_hash,
+                ob.binding_hash AS canonical_output_binding_hash,
+                ob.data_source_uid::text AS canonical_output_data_source_uid,
+                ob.object_kind AS canonical_output_object_kind,
+                ob.object_ref AS canonical_output_object_ref,
+                ob.dialect AS canonical_output_dialect,
                 b.component_kind,
-                b.idempotency AS binding_idempotency
+                b.idempotency AS binding_idempotency,
+                EXISTS (
+                    SELECT 1 FROM public.rule_publication_audits pa
+                    WHERE pa.rule_execution_plan_id = p.id
+                      AND pa.rule_version_id = r.id
+                      AND pa.action = 'published'
+                      AND pa.to_status = 'published'
+                      AND pa.evidence_hash = p.plan_hash
+                ) AS publication_audit_trusted,
+                EXISTS (
+                    SELECT 1
+                    FROM public.rule_logical_plans lp
+                    JOIN public.rule_logical_compile_evidence lce
+                      ON lce.logical_plan_id = lp.id
+                    JOIN public.rule_logical_test_evidence lte
+                      ON lte.logical_plan_id = lp.id
+                    WHERE lp.rule_version_id = r.id
+                      AND lp.status = 'published'
+                      AND lce.status = 'success'
+                      AND lce.compiler_version = lp.compiler_version
+                      AND lce.plan_hash = lp.plan_hash
+                      AND lce.schema_hashes = lp.schema_hashes
+                      AND lce.capabilities = lp.capabilities
+                      AND lte.status = 'success'
+                      AND lte.plan_hash = lp.plan_hash
+                      AND lte.schema_hashes = lp.schema_hashes
+                ) AS logical_evidence_trusted,
+                EXISTS (
+                    SELECT 1
+                    FROM public.rule_compile_evidence pce
+                    JOIN public.rule_test_evidence pte
+                      ON pte.rule_execution_plan_id =
+                         pce.rule_execution_plan_id
+                    WHERE pce.rule_execution_plan_id = p.id
+                      AND pce.status = 'success'
+                      AND pce.legacy_untrusted = FALSE
+                      AND pce.compiler_version = p.compiler_version
+                      AND pce.plan_hash = p.plan_hash
+                      AND pce.schema_hashes = p.schema_hashes
+                      AND pce.binding_hashes = jsonb_build_object(
+                          'input', ib.binding_hash,
+                          'output', ob.binding_hash
+                      )
+                      AND pce.capabilities = CASE
+                          WHEN p.backend = 'polars_batch' THEN
+                              jsonb_build_object(
+                                  'resource_limits',
+                                  p.plan->'resource_limits'
+                              )
+                          ELSE p.plan->'capabilities'
+                      END
+                      AND pte.status = 'success'
+                      AND pte.legacy_untrusted = FALSE
+                      AND pte.plan_hash = p.plan_hash
+                      AND pte.schema_hashes = p.schema_hashes
+                      AND pte.binding_hashes = jsonb_build_object(
+                          'input', ib.binding_hash,
+                          'output', ob.binding_hash
+                      )
+                ) AS physical_evidence_trusted
             FROM public.rule_execution_plans p
             JOIN public.dataflow_component_bindings b
               ON b.id = p.component_binding_id
@@ -315,6 +385,9 @@ class RulePlanExecutor:
             or record.get("plan_hash") != plan_hash
             or record.get("plan_status") != "published"
             or record.get("rule_status") != "published"
+            or record.get("publication_audit_trusted") is not True
+            or record.get("logical_evidence_trusted") is not True
+            or record.get("physical_evidence_trusted") is not True
             or _canonical_hash(record.get("plan")) != plan_hash
         ):
             raise NodeExecutionError("published rule plan is not executable")
@@ -329,6 +402,7 @@ class RulePlanExecutor:
         if backend == "sql_pushdown":
             try:
                 plan = validate_bound_sql_plan(record.get("plan"))
+                relations = bound_sql_plan_relations(plan)
             except ValueError as exc:
                 raise NodeExecutionError(
                     "published rule plan is not executable"
@@ -358,6 +432,26 @@ class RulePlanExecutor:
                 != plan["output_schema_snapshot_id"]
                 or record.get("canonical_output_schema_hash")
                 != plan["output_schema_hash"]
+                or record.get("canonical_input_data_source_uid")
+                != plan["data_source_uid"]
+                or record.get("canonical_output_data_source_uid")
+                != plan["data_source_uid"]
+                or record.get("canonical_input_object_ref")
+                != relations["input_object_ref"]
+                or record.get("canonical_output_object_ref")
+                != relations["output_object_ref"]
+                or (
+                    "postgresql"
+                    if record.get("canonical_input_dialect") == "postgres"
+                    else record.get("canonical_input_dialect")
+                )
+                != plan["dialect"]
+                or (
+                    "postgresql"
+                    if record.get("canonical_output_dialect") == "postgres"
+                    else record.get("canonical_output_dialect")
+                )
+                != plan["dialect"]
             ):
                 raise NodeExecutionError(
                     "published rule plan canonical attestation does not match"
@@ -394,6 +488,14 @@ class RulePlanExecutor:
                 != plan["output_schema_snapshot_id"]
                 or record.get("canonical_output_schema_hash")
                 != plan["output_schema_hash"]
+                or record.get("canonical_input_object_kind")
+                != "parquet_artifact"
+                or record.get("canonical_output_object_kind")
+                != "parquet_artifact"
+                or record.get("canonical_input_binding_hash")
+                != plan["input_binding_hash"]
+                or record.get("canonical_output_binding_hash")
+                != plan["output_binding_hash"]
             ):
                 raise NodeExecutionError(
                     "published rule plan canonical attestation does not match"

+ 1 - 0
deploy/docker/docker-compose.yml

@@ -378,6 +378,7 @@ services:
       QWEN_EMBEDDING_API_KEY: ${QWEN_EMBEDDING_API_KEY:-}
       QWEN_EMBEDDING_MODEL: ${QWEN_EMBEDDING_MODEL:-text-embedding-v3}
       SECRET_KEY: dataops-local-test-secret-key
+      RULE_GENERATION_RECEIPT_SECRET: ${RULE_GENERATION_RECEIPT_SECRET:-}
       DATASOURCE_CREDENTIAL_MASTER_KEY: ${DATASOURCE_CREDENTIAL_MASTER_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
       DATASOURCE_CREDENTIAL_KEY_VERSION: ${DATASOURCE_CREDENTIAL_KEY_VERSION:-v1}
       LOG_DIR: /app/logs

+ 33 - 4
migrations/versions/20260723_200_rule_publication_gates.py

@@ -13,6 +13,18 @@ def upgrade() -> None:
         """
         DO $$
         BEGIN
+            IF EXISTS (
+                SELECT 1 FROM public.data_rule_versions
+                WHERE status = 'published'
+            ) OR EXISTS (
+                SELECT 1 FROM public.rule_execution_plans
+                WHERE status = 'published'
+            ) THEN
+                RAISE EXCEPTION
+                    'legacy published rules or plans cannot be trusted by '
+                    'migration 200; revoke or rebuild them through the '
+                    'Task7 compile-test-publication chain';
+            END IF;
             IF EXISTS (
                 SELECT 1 FROM public.data_rule_versions
                 WHERE status NOT IN ('draft','validated','published')
@@ -67,12 +79,27 @@ def upgrade() -> None:
             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
+            input_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),
+            input_schema_hash CHAR(64) NOT NULL,
+            input_fields JSONB NOT NULL,
+            output_schema_snapshot_id UUID NOT NULL
+                REFERENCES public.data_schema_snapshots(id) ON DELETE RESTRICT,
+            output_schema_hash CHAR(64) NOT NULL,
+            output_fields JSONB NOT NULL,
+            input_sample_artifact_ref VARCHAR(500) NOT NULL,
+            input_sample_artifact_digest CHAR(64) NOT NULL,
+            golden_output_artifact_ref VARCHAR(500),
+            golden_output_artifact_digest CHAR(64),
             context_hash CHAR(64) NOT NULL,
-            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (
+                (golden_output_artifact_ref IS NULL
+                 AND golden_output_artifact_digest IS NULL)
+                OR
+                (golden_output_artifact_ref IS NOT NULL
+                 AND golden_output_artifact_digest IS NOT NULL)
+            )
         );
 
         CREATE TABLE public.rule_logical_plans (
@@ -133,6 +160,7 @@ def upgrade() -> None:
             ADD COLUMN schema_hashes JSONB,
             ADD COLUMN binding_hashes JSONB,
             ADD COLUMN capabilities JSONB,
+            ADD COLUMN legacy_untrusted BOOLEAN NOT NULL DEFAULT TRUE,
             ADD COLUMN created_by UUID REFERENCES public.users(id)
                 ON DELETE SET NULL;
         UPDATE public.rule_compile_evidence e
@@ -159,6 +187,7 @@ def upgrade() -> None:
             ADD COLUMN schema_hashes JSONB,
             ADD COLUMN binding_hashes JSONB,
             ADD COLUMN run_id UUID,
+            ADD COLUMN legacy_untrusted BOOLEAN NOT NULL DEFAULT TRUE,
             ADD COLUMN created_by UUID REFERENCES public.users(id)
                 ON DELETE SET NULL;
         UPDATE public.rule_test_evidence e

+ 5 - 0
tests/core/data_rules/test_authoring.py

@@ -168,6 +168,11 @@ def test_authoring_repairs_only_deterministic_contract_errors_twice():
     assert "DETERMINISTIC_VALIDATION_ERROR" in model.calls[1]["messages"][-1][
         "content"
     ]
+    prompt_hashes = [
+        item["prompt_hash"] for item in result["generation_attempts"]
+    ]
+    assert len(set(prompt_hashes)) == 3
+    assert result["prompt_hash"] == prompt_hashes[-1]
 
 
 def test_authoring_does_not_repair_ambiguity_or_low_confidence():

+ 6 - 0
tests/core/data_rules/test_data_rule_repository.py

@@ -256,6 +256,12 @@ def test_server_catalog_loader_uses_only_published_database_versions():
     assert rules[standard_rule_id]["execution_plan"]["plan_hash"] == "d" * 64
     assert "rv.status = 'published'" in _sql(session)
     assert "sv.status = 'published'" in _sql(session)
+    assert "legacy_untrusted = FALSE" in _sql(session)
+    assert "rule_publication_audits" in _sql(session)
+    assert "rule_logical_compile_evidence" in _sql(session)
+    assert "rule_logical_test_evidence" in _sql(session)
+    assert "rule_compile_evidence" in _sql(session)
+    assert "rule_test_evidence" in _sql(session)
 
 
 def test_generation_run_persists_model_hashes_uncertainty_and_decision():

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

@@ -238,6 +238,46 @@ def test_generation_receipt_rejects_expired_and_modified_signatures():
         )
 
 
+def test_generation_receipt_enforces_issued_at_ttl_and_clock_skew():
+    from app.core.data_rules.publication import (
+        GenerationReceiptSigner,
+        generation_receipt_claims,
+    )
+
+    now = datetime(2026, 7, 24, 0, 0, tzinfo=UTC)
+    actor = new_governance_uid()
+    spec = valid_rule_spec()
+    signer = GenerationReceiptSigner(
+        "test-secret-with-sufficient-entropy",
+        clock=lambda: now,
+        max_ttl_seconds=600,
+        clock_skew_seconds=30,
+    )
+
+    too_long = 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,
+        issued_at=now,
+        expires_at=now + timedelta(seconds=601),
+    )
+    with pytest.raises(ValueError, match="TTL"):
+        signer.issue(too_long)
+
+    future = {
+        **too_long,
+        "issued_at": int((now + timedelta(seconds=31)).timestamp()),
+        "expires_at": int((now + timedelta(seconds=60)).timestamp()),
+    }
+    with pytest.raises(ValueError, match="future|clock"):
+        signer.issue(future)
+
+
 def test_rule_publication_requires_server_compile_and_test_evidence():
     from app.core.data_rules.publication import (
         GenerationReceiptSigner,

+ 307 - 0
tests/integration/test_data_rule_polars_execution.py

@@ -671,6 +671,241 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution(tmp_path):
                     ),
                 },
             )
+            for trusted in (
+                {
+                    "rule_id": rule_id,
+                    "plan_id": plan_id,
+                    "compiled": compiled,
+                    "input_schema": input_schema,
+                    "output_schema": output_schema,
+                },
+                {
+                    "rule_id": downstream_rule_id,
+                    "plan_id": downstream_plan_id,
+                    "compiled": downstream_compiled,
+                    "input_schema": output_schema,
+                    "output_schema": output_schema,
+                },
+            ):
+                trusted_plan = trusted["compiled"]["plan"]
+                trusted_schema_hashes = {
+                    "input": trusted["input_schema"]["schema_hash"],
+                    "output": trusted["output_schema"]["schema_hash"],
+                }
+                trusted_binding_hashes = {
+                    "input": trusted_plan["input_binding_hash"],
+                    "output": trusted_plan["output_binding_hash"],
+                }
+                profile_id = new_governance_uid()
+                logical_plan_id = new_governance_uid()
+                connection.execute(
+                    text(
+                        """
+                        INSERT INTO public.rule_validation_profiles
+                        (id, rule_version_id, input_schema_snapshot_id,
+                         input_schema_hash, input_fields,
+                         output_schema_snapshot_id, output_schema_hash,
+                         output_fields, input_sample_artifact_ref,
+                         input_sample_artifact_digest, context_hash)
+                        VALUES
+                        (CAST(:id AS uuid), CAST(:rule_id AS uuid),
+                         CAST(:input_snapshot_id AS uuid), :input_hash,
+                         CAST(:input_fields AS jsonb),
+                         CAST(:output_snapshot_id AS uuid), :output_hash,
+                         CAST(:output_fields AS jsonb), :artifact_ref,
+                         :digest, :digest)
+                        """
+                    ),
+                    {
+                        "id": profile_id,
+                        "rule_id": trusted["rule_id"],
+                        "input_snapshot_id": trusted["input_schema"]["id"],
+                        "input_hash": trusted["input_schema"]["schema_hash"],
+                        "input_fields": json.dumps(
+                            trusted["input_schema"]["fields"]
+                        ),
+                        "output_snapshot_id": trusted["output_schema"]["id"],
+                        "output_hash": trusted["output_schema"]["schema_hash"],
+                        "output_fields": json.dumps(
+                            trusted["output_schema"]["fields"]
+                        ),
+                        "artifact_ref": f"test://{trusted['rule_id']}",
+                        "digest": "a" * 64,
+                    },
+                )
+                connection.execute(
+                    text(
+                        """
+                        INSERT INTO public.rule_logical_plans
+                        (id, rule_version_id, validation_profile_id,
+                         compiler_version, backend, plan, plan_hash,
+                         schema_hashes, capabilities, status)
+                        VALUES
+                        (CAST(:id AS uuid), CAST(:rule_id AS uuid),
+                         CAST(:profile_id AS uuid), :compiler_version,
+                         'polars_batch', CAST(:plan AS jsonb), :plan_hash,
+                         CAST(:schema_hashes AS jsonb), '{}'::jsonb,
+                         'published')
+                        """
+                    ),
+                    {
+                        "id": logical_plan_id,
+                        "rule_id": trusted["rule_id"],
+                        "profile_id": profile_id,
+                        "compiler_version": trusted["compiled"][
+                            "compiler_version"
+                        ],
+                        "plan": json.dumps(trusted_plan),
+                        "plan_hash": trusted["compiled"]["plan_hash"],
+                        "schema_hashes": json.dumps(trusted_schema_hashes),
+                    },
+                )
+                connection.execute(
+                    text(
+                        """
+                        INSERT INTO public.rule_logical_compile_evidence
+                        (id, logical_plan_id, compiler_version,
+                         compiler_digest, plan_hash, schema_hashes,
+                         capabilities, status)
+                        VALUES
+                        (CAST(:id AS uuid), CAST(:logical_plan_id AS uuid),
+                         :compiler_version, :digest, :plan_hash,
+                         CAST(:schema_hashes AS jsonb), '{}'::jsonb,
+                         'success')
+                        """
+                    ),
+                    {
+                        "id": new_governance_uid(),
+                        "logical_plan_id": logical_plan_id,
+                        "compiler_version": trusted["compiled"][
+                            "compiler_version"
+                        ],
+                        "digest": "b" * 64,
+                        "plan_hash": trusted["compiled"]["plan_hash"],
+                        "schema_hashes": json.dumps(trusted_schema_hashes),
+                    },
+                )
+                connection.execute(
+                    text(
+                        """
+                        INSERT INTO public.rule_logical_test_evidence
+                        (id, logical_plan_id, test_kind, evidence_hash,
+                         run_id, plan_hash, schema_hashes, evidence, status)
+                        VALUES
+                        (CAST(:id AS uuid), CAST(:logical_plan_id AS uuid),
+                         'dry_run', :digest, CAST(:run_id AS uuid), :plan_hash,
+                         CAST(:schema_hashes AS jsonb), '{}'::jsonb, 'success')
+                        """
+                    ),
+                    {
+                        "id": new_governance_uid(),
+                        "logical_plan_id": logical_plan_id,
+                        "digest": "c" * 64,
+                        "run_id": new_governance_uid(),
+                        "plan_hash": trusted["compiled"]["plan_hash"],
+                        "schema_hashes": json.dumps(trusted_schema_hashes),
+                    },
+                )
+                connection.execute(
+                    text(
+                        """
+                        INSERT INTO public.rule_compile_evidence
+                        (id, rule_execution_plan_id, compiler_version,
+                         compiler_digest, status, evidence, plan_hash,
+                         schema_hashes, binding_hashes, capabilities,
+                         legacy_untrusted)
+                        VALUES
+                        (CAST(:id AS uuid), CAST(:plan_id AS uuid),
+                         :compiler_version, :digest, 'success', '{}'::jsonb,
+                         :plan_hash, CAST(:schema_hashes AS jsonb),
+                         CAST(:binding_hashes AS jsonb),
+                         CAST(:capabilities AS jsonb), FALSE)
+                        """
+                    ),
+                    {
+                        "id": new_governance_uid(),
+                        "plan_id": trusted["plan_id"],
+                        "compiler_version": trusted["compiled"][
+                            "compiler_version"
+                        ],
+                        "digest": "d" * 64,
+                        "plan_hash": trusted["compiled"]["plan_hash"],
+                        "schema_hashes": json.dumps(
+                            {
+                                "rule_spec_hash": trusted_plan[
+                                    "rule_spec_hash"
+                                ],
+                                "input_schema_snapshot_id": trusted_plan[
+                                    "input_schema_snapshot_id"
+                                ],
+                                "input_schema_hash": trusted_plan[
+                                    "input_schema_hash"
+                                ],
+                                "output_schema_snapshot_id": trusted_plan[
+                                    "output_schema_snapshot_id"
+                                ],
+                                "output_schema_hash": trusted_plan[
+                                    "output_schema_hash"
+                                ],
+                            }
+                        ),
+                        "binding_hashes": json.dumps(
+                            trusted_binding_hashes
+                        ),
+                        "capabilities": json.dumps(
+                            {
+                                "resource_limits": trusted_plan[
+                                    "resource_limits"
+                                ]
+                            }
+                        ),
+                    },
+                )
+                connection.execute(
+                    text(
+                        """
+                        INSERT INTO public.rule_test_evidence
+                        (id, rule_execution_plan_id, test_kind, evidence_hash,
+                         status, evidence, plan_hash, schema_hashes,
+                         binding_hashes, run_id, legacy_untrusted)
+                        SELECT
+                         CAST(:id AS uuid), p.id, 'sample', :digest,
+                         'success', '{}'::jsonb, p.plan_hash, p.schema_hashes,
+                         CAST(:binding_hashes AS jsonb),
+                         CAST(:run_id AS uuid), FALSE
+                        FROM public.rule_execution_plans p
+                        WHERE p.id = CAST(:plan_id AS uuid)
+                        """
+                    ),
+                    {
+                        "id": new_governance_uid(),
+                        "plan_id": trusted["plan_id"],
+                        "digest": "e" * 64,
+                        "binding_hashes": json.dumps(
+                            trusted_binding_hashes
+                        ),
+                        "run_id": new_governance_uid(),
+                    },
+                )
+                connection.execute(
+                    text(
+                        """
+                        INSERT INTO public.rule_publication_audits
+                        (id, rule_version_id, rule_execution_plan_id,
+                         action, to_status, evidence_hash)
+                        VALUES
+                        (CAST(:id AS uuid), CAST(:rule_id AS uuid),
+                         CAST(:plan_id AS uuid), 'published', 'published',
+                         :plan_hash)
+                        """
+                    ),
+                    {
+                        "id": new_governance_uid(),
+                        "rule_id": trusted["rule_id"],
+                        "plan_id": trusted["plan_id"],
+                        "plan_hash": trusted["compiled"]["plan_hash"],
+                    },
+                )
         customer_path = tmp_path / "customers.parquet"
         segment_path = tmp_path / "segments.parquet"
         pl.DataFrame(customer_rows).write_parquet(customer_path)
@@ -2127,6 +2362,32 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution(tmp_path):
                 ),
                 {"correlation_id": correlation_id},
             )
+            connection.execute(
+                text(
+                    "DELETE FROM public.rule_publication_audits "
+                    "WHERE rule_execution_plan_id IN (CAST(:id AS uuid), "
+                    "CAST(:downstream_id AS uuid))"
+                ),
+                {
+                    "id": plan_id,
+                    "downstream_id": downstream_plan_id,
+                },
+            )
+            for evidence_table in (
+                "rule_test_evidence",
+                "rule_compile_evidence",
+            ):
+                connection.execute(
+                    text(
+                        f"DELETE FROM public.{evidence_table} "
+                        "WHERE rule_execution_plan_id IN "
+                        "(CAST(:id AS uuid), CAST(:downstream_id AS uuid))"
+                    ),
+                    {
+                        "id": plan_id,
+                        "downstream_id": downstream_plan_id,
+                    },
+                )
             connection.execute(
                 text(
                     "DELETE FROM public.rule_execution_plans "
@@ -2199,6 +2460,52 @@ def test_real_postgres_mysql_minio_polars_cross_source_execution(tmp_path):
                 ),
                 {"id": dataflow_version_id},
             )
+            connection.execute(
+                text(
+                    "DELETE FROM public.rule_logical_test_evidence "
+                    "WHERE logical_plan_id IN (SELECT id FROM "
+                    "public.rule_logical_plans WHERE rule_version_id IN "
+                    "(CAST(:id AS uuid), CAST(:downstream_id AS uuid)))"
+                ),
+                {
+                    "id": rule_id,
+                    "downstream_id": downstream_rule_id,
+                },
+            )
+            connection.execute(
+                text(
+                    "DELETE FROM public.rule_logical_compile_evidence "
+                    "WHERE logical_plan_id IN (SELECT id FROM "
+                    "public.rule_logical_plans WHERE rule_version_id IN "
+                    "(CAST(:id AS uuid), CAST(:downstream_id AS uuid)))"
+                ),
+                {
+                    "id": rule_id,
+                    "downstream_id": downstream_rule_id,
+                },
+            )
+            connection.execute(
+                text(
+                    "DELETE FROM public.rule_logical_plans "
+                    "WHERE rule_version_id IN "
+                    "(CAST(:id AS uuid), CAST(:downstream_id AS uuid))"
+                ),
+                {
+                    "id": rule_id,
+                    "downstream_id": downstream_rule_id,
+                },
+            )
+            connection.execute(
+                text(
+                    "DELETE FROM public.rule_validation_profiles "
+                    "WHERE rule_version_id IN "
+                    "(CAST(:id AS uuid), CAST(:downstream_id AS uuid))"
+                ),
+                {
+                    "id": rule_id,
+                    "downstream_id": downstream_rule_id,
+                },
+            )
             connection.execute(
                 text(
                     "DELETE FROM public.data_rule_versions "

+ 21 - 0
tests/integration/test_data_rule_sql_execution.py

@@ -106,10 +106,28 @@ class PlanRepository:
                 "input_schema_snapshot_id"
             ],
             "canonical_input_schema_hash": plan["input_schema_hash"],
+            "canonical_input_data_source_uid": self.context[
+                "input_binding"
+            ]["data_source_uid"],
+            "canonical_input_object_ref": self.context["input_binding"][
+                "object_ref"
+            ],
+            "canonical_input_dialect": self.context["input_binding"][
+                "dialect"
+            ],
             "canonical_output_schema_snapshot_id": plan[
                 "output_schema_snapshot_id"
             ],
             "canonical_output_schema_hash": plan["output_schema_hash"],
+            "canonical_output_data_source_uid": self.context[
+                "output_binding"
+            ]["data_source_uid"],
+            "canonical_output_object_ref": self.context["output_binding"][
+                "object_ref"
+            ],
+            "canonical_output_dialect": self.context["output_binding"][
+                "dialect"
+            ],
             "plan_status": kwargs["status"],
             "rule_status": "published",
             "component_kind": "rule.apply",
@@ -128,6 +146,9 @@ class PlanRepository:
         assert evidence["commit_outcome"] == "committed"
         assert evidence["rows_in"] >= evidence["rows_out"]
         self.record["plan_status"] = "published"
+        self.record["publication_audit_trusted"] = True
+        self.record["logical_evidence_trusted"] = True
+        self.record["physical_evidence_trusted"] = True
 
     def load(self, **_kwargs):
         return dict(self.record)

+ 792 - 0
tests/integration/test_rule_physical_publication_lifecycle.py

@@ -0,0 +1,792 @@
+from __future__ import annotations
+
+import json
+import time
+from concurrent.futures import ThreadPoolExecutor
+
+import pytest
+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.compilers.sql import SqlGlotRuleCompiler
+from app.core.data_rules.contracts import rule_spec_hash, validate_rule_spec
+from app.core.data_rules.publication import (
+    PhysicalPlanPublicationService,
+    ServerOwnedPhysicalPreflightRunner,
+)
+from app.core.data_rules.repository import DataRuleRepository
+from tests.integration.test_data_rule_polars_execution import _compose_value
+from tests.integration.test_data_rule_sql_execution import (
+    CASES,
+    ReadOnlyPreflightManager,
+    _snapshot,
+)
+
+pytestmark = pytest.mark.integration
+
+
+def _platform_url() -> str:
+    user = _compose_value(r"POSTGRES_USER:\s*([^\s]+)")
+    password = _compose_value(r"POSTGRES_PASSWORD:\s*([^\s]+)")
+    port = _compose_value(r'"(15432):5432"')
+    return (
+        f"postgresql+psycopg2://{user}:{password}"
+        f"@127.0.0.1:{port}/dataops"
+    )
+
+
+def _insert_logical_trust(
+    connection,
+    *,
+    rule_id: str,
+    actor_id: str,
+    input_schema: dict,
+    output_schema: dict,
+    compiled: dict,
+) -> None:
+    profile_id = new_governance_uid()
+    logical_id = new_governance_uid()
+    logical_schema_hashes = {
+        "input": input_schema["schema_hash"],
+        "output": output_schema["schema_hash"],
+    }
+    capabilities = compiled["plan"]["capabilities"]
+    connection.execute(
+        text(
+            """
+            INSERT INTO public.rule_validation_profiles
+            (id, rule_version_id, input_schema_snapshot_id,
+             input_schema_hash, input_fields, output_schema_snapshot_id,
+             output_schema_hash, output_fields,
+             input_sample_artifact_ref, input_sample_artifact_digest,
+             context_hash)
+            VALUES
+            (CAST(:id AS uuid), CAST(:rule_id AS uuid),
+             CAST(:input_id AS uuid), :input_hash,
+             CAST(:input_fields AS jsonb), CAST(:output_id AS uuid),
+             :output_hash, CAST(:output_fields AS jsonb),
+             'test://physical-publication-input', :digest, :digest)
+            """
+        ),
+        {
+            "id": profile_id,
+            "rule_id": rule_id,
+            "input_id": input_schema["id"],
+            "input_hash": input_schema["schema_hash"],
+            "input_fields": json.dumps(input_schema["fields"]),
+            "output_id": output_schema["id"],
+            "output_hash": output_schema["schema_hash"],
+            "output_fields": json.dumps(output_schema["fields"]),
+            "digest": "a" * 64,
+        },
+    )
+    connection.execute(
+        text(
+            """
+            INSERT INTO public.rule_logical_plans
+            (id, rule_version_id, validation_profile_id, compiler_version,
+             backend, plan, plan_hash, schema_hashes, capabilities, status)
+            VALUES
+            (CAST(:id AS uuid), CAST(:rule_id AS uuid),
+             CAST(:profile_id AS uuid), :compiler_version, 'sql_pushdown',
+             CAST(:plan AS jsonb), :plan_hash, CAST(:schema_hashes AS jsonb),
+             CAST(:capabilities AS jsonb), 'published')
+            """
+        ),
+        {
+            "id": logical_id,
+            "rule_id": rule_id,
+            "profile_id": profile_id,
+            "compiler_version": compiled["compiler_version"],
+            "plan": json.dumps(compiled["plan"]),
+            "plan_hash": compiled["plan_hash"],
+            "schema_hashes": json.dumps(logical_schema_hashes),
+            "capabilities": json.dumps(capabilities),
+        },
+    )
+    connection.execute(
+        text(
+            """
+            INSERT INTO public.rule_logical_compile_evidence
+            (id, logical_plan_id, compiler_version, compiler_digest,
+             plan_hash, schema_hashes, capabilities, status, created_by)
+            VALUES
+            (CAST(:id AS uuid), CAST(:logical_id AS uuid),
+             :compiler_version, :digest, :plan_hash,
+             CAST(:schema_hashes AS jsonb), CAST(:capabilities AS jsonb),
+             'success', CAST(:actor_id AS uuid))
+            """
+        ),
+        {
+            "id": new_governance_uid(),
+            "logical_id": logical_id,
+            "compiler_version": compiled["compiler_version"],
+            "digest": "b" * 64,
+            "plan_hash": compiled["plan_hash"],
+            "schema_hashes": json.dumps(logical_schema_hashes),
+            "capabilities": json.dumps(capabilities),
+            "actor_id": actor_id,
+        },
+    )
+    connection.execute(
+        text(
+            """
+            INSERT INTO public.rule_logical_test_evidence
+            (id, logical_plan_id, test_kind, evidence_hash, run_id,
+             plan_hash, schema_hashes, evidence, status, created_by)
+            VALUES
+            (CAST(:id AS uuid), CAST(:logical_id AS uuid), 'dry_run',
+             :digest, CAST(:run_id AS uuid), :plan_hash,
+             CAST(:schema_hashes AS jsonb), '{}'::jsonb, 'success',
+             CAST(:actor_id AS uuid))
+            """
+        ),
+        {
+            "id": new_governance_uid(),
+            "logical_id": logical_id,
+            "digest": "c" * 64,
+            "run_id": new_governance_uid(),
+            "plan_hash": compiled["plan_hash"],
+            "schema_hashes": json.dumps(logical_schema_hashes),
+            "actor_id": actor_id,
+        },
+    )
+
+
+@pytest.mark.parametrize(
+    ("dialect", "url", "schema_name", "collation", "regex_engine"), CASES
+)
+def test_physical_service_rejects_post_test_canonical_drift_and_replays(
+    dialect, url, schema_name, collation, regex_engine
+):
+    source_engine = create_engine(url, pool_pre_ping=True)
+    platform_engine = create_engine(_platform_url(), pool_pre_ping=True)
+    suffix = dialect.replace("postgresql", "pg")
+    source_table = f"task7_publish_source_{suffix}"
+    target_table = f"task7_publish_target_{suffix}"
+    source_ref = f"{schema_name}.{source_table}"
+    target_ref = f"{schema_name}.{target_table}"
+    input_schema = _snapshot(f"bd:task7:publish:{dialect}:input")
+    output_schema = _snapshot(f"bd:task7:publish:{dialect}:output")
+    data_source_uid = new_governance_uid()
+    input_binding = {
+        "id": new_governance_uid(),
+        "data_source_uid": data_source_uid,
+        "object_kind": "table",
+        "object_ref": source_ref,
+        "schema_snapshot_id": input_schema["id"],
+        "access_mode": "read",
+        "dialect": dialect,
+        "write_mode": "append",
+    }
+    output_binding = {
+        "id": new_governance_uid(),
+        "data_source_uid": data_source_uid,
+        "object_kind": "table",
+        "object_ref": target_ref,
+        "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_physical_publication_{dialect}",
+            "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()
+    compiled = SqlGlotRuleCompiler(dialect).compile(
+        rule_version={
+            "id": rule_id,
+            "status": "published",
+            "rule_spec": spec,
+            "spec_hash": rule_spec_hash(spec),
+        },
+        input_schema=input_schema,
+        output_schema=output_schema,
+        input_binding=input_binding,
+        output_binding=output_binding,
+        backend={
+            "dialect": dialect,
+            "timezone": "Asia/Shanghai",
+            "collation": collation,
+            "rounding_mode": "half_away_from_zero",
+            "regex_engine": regex_engine,
+        },
+    )
+    try:
+        with source_engine.begin() as source:
+            source.execute(text(f"DROP TABLE IF EXISTS {target_table}"))
+            source.execute(text(f"DROP TABLE IF EXISTS {source_table}"))
+            source.execute(
+                text(
+                    f"CREATE TABLE {source_table} ("
+                    "customer_id BIGINT PRIMARY KEY, name VARCHAR(100), "
+                    "mobile VARCHAR(30))"
+                )
+            )
+            source.execute(
+                text(
+                    f"CREATE TABLE {target_table} ("
+                    "customer_id BIGINT PRIMARY KEY, name VARCHAR(100), "
+                    "mobile VARCHAR(30))"
+                )
+            )
+            source.execute(
+                text(
+                    f"INSERT INTO {source_table} "
+                    "(customer_id, name, mobile) VALUES "
+                    "(1, ' Alice ', '13800138000')"
+                )
+            )
+        with platform_engine.connect() as connection:
+            transaction = connection.begin()
+            try:
+                actor_id = new_governance_uid()
+                rule_uid = spec["rule_uid"]
+                dataflow_version_id = new_governance_uid()
+                deployment_id = new_governance_uid()
+                component_id = new_governance_uid()
+                connection.execute(
+                    text(
+                        "INSERT INTO public.users "
+                        "(id, username, display_name, password_hash, status) "
+                        "VALUES (CAST(:id AS uuid), :username, 'Task7', "
+                        "'not-a-login-secret', 'active')"
+                    ),
+                    {
+                        "id": actor_id,
+                        "username": f"task7-{actor_id[:8]}",
+                    },
+                )
+                for snapshot in (input_schema, output_schema):
+                    connection.execute(
+                        text(
+                            "INSERT INTO public.data_schema_snapshots "
+                            "(id, schema_ref, schema_hash, fields, "
+                            "source_revision) VALUES "
+                            "(CAST(:id AS uuid), :schema_ref, :schema_hash, "
+                            "CAST(:fields AS jsonb), :source_revision)"
+                        ),
+                        {
+                            **snapshot,
+                            "fields": json.dumps(snapshot["fields"]),
+                        },
+                    )
+                connection.execute(
+                    text(
+                        "INSERT INTO public.data_rules "
+                        "(id, rule_uid, name, category, status) VALUES "
+                        "(CAST(:id AS uuid), CAST(:rule_uid AS uuid), "
+                        ":name, 'general', 'active')"
+                    ),
+                    {
+                        "id": new_governance_uid(),
+                        "rule_uid": rule_uid,
+                        "name": spec["name"],
+                    },
+                )
+                connection.execute(
+                    text(
+                        "INSERT INTO public.data_rule_versions "
+                        "(id, rule_uid, version_no, source_text, "
+                        "source_language, rule_spec, spec_hash, "
+                        "generated_kind, status, published_at) VALUES "
+                        "(CAST(:id AS uuid), CAST(:rule_uid AS uuid), 1, "
+                        "'Task7 physical publication', 'en', "
+                        "CAST(:rule_spec AS jsonb), :spec_hash, 'sql', "
+                        "'published', CURRENT_TIMESTAMP)"
+                    ),
+                    {
+                        "id": rule_id,
+                        "rule_uid": rule_uid,
+                        "rule_spec": json.dumps(spec),
+                        "spec_hash": rule_spec_hash(spec),
+                    },
+                )
+                _insert_logical_trust(
+                    connection,
+                    rule_id=rule_id,
+                    actor_id=actor_id,
+                    input_schema=input_schema,
+                    output_schema=output_schema,
+                    compiled=compiled,
+                )
+                connection.execute(
+                    text(
+                        "INSERT INTO public.dataflow_versions "
+                        "(id, dataflow_uid, version_no, name, dataflow_spec, "
+                        "input_schema_hashes, output_schema_hash, status, "
+                        "released_at) VALUES "
+                        "(CAST(:id AS uuid), CAST(:uid AS uuid), 1, "
+                        "'Task7 physical publication', '{}'::jsonb, "
+                        "CAST(:inputs AS jsonb), :output, 'released', "
+                        "CURRENT_TIMESTAMP)"
+                    ),
+                    {
+                        "id": dataflow_version_id,
+                        "uid": new_governance_uid(),
+                        "inputs": json.dumps([input_schema["schema_hash"]]),
+                        "output": output_schema["schema_hash"],
+                    },
+                )
+                connection.execute(
+                    text(
+                        "INSERT INTO public.dataflow_deployments "
+                        "(id, dataflow_version_id, environment, "
+                        "deployment_config, status) VALUES "
+                        "(CAST(:id AS uuid), CAST(:version_id AS uuid), "
+                        "'test', '{}'::jsonb, 'disabled')"
+                    ),
+                    {
+                        "id": deployment_id,
+                        "version_id": dataflow_version_id,
+                    },
+                )
+                connection.execute(
+                    text(
+                        "INSERT INTO public.dataflow_component_bindings "
+                        "(id, dataflow_version_id, component_id, "
+                        "component_kind, rule_version_id, stage, order_no, "
+                        "idempotency, provenance) VALUES "
+                        "(CAST(:id AS uuid), CAST(:version_id AS uuid), "
+                        "'task7_publish', 'rule.apply', "
+                        "CAST(:rule_id AS uuid), 'transform', 0, "
+                        "'{\"strategy\":\"upsert\",\"key\":\"customer_id\"}'"
+                        "::jsonb, '{}'::jsonb)"
+                    ),
+                    {
+                        "id": component_id,
+                        "version_id": dataflow_version_id,
+                        "rule_id": rule_id,
+                    },
+                )
+                input_binding_hash = "d" * 64
+                output_binding_hash = "e" * 64
+                for logical_ref, binding, binding_hash in (
+                    (
+                        "input",
+                        input_binding,
+                        input_binding_hash,
+                    ),
+                    (
+                        "output",
+                        output_binding,
+                        output_binding_hash,
+                    ),
+                ):
+                    connection.execute(
+                        text(
+                            "INSERT INTO public.dataflow_dataset_bindings "
+                            "(id, dataflow_deployment_id, logical_ref, "
+                            "data_source_uid, object_kind, object_ref, "
+                            "schema_snapshot_id, dialect, access_mode, "
+                            "write_mode, binding_hash) VALUES "
+                            "(CAST(:id AS uuid), CAST(:deployment_id AS uuid), "
+                            ":logical_ref, CAST(:source_uid AS uuid), 'table', "
+                            ":object_ref, CAST(:snapshot_id AS uuid), "
+                            ":dialect, :access_mode, 'append', :binding_hash)"
+                        ),
+                        {
+                            "id": binding["id"],
+                            "deployment_id": deployment_id,
+                            "logical_ref": logical_ref,
+                            "source_uid": data_source_uid,
+                            "object_ref": binding["object_ref"],
+                            "snapshot_id": binding["schema_snapshot_id"],
+                            "dialect": dialect,
+                            "access_mode": binding["access_mode"],
+                            "binding_hash": binding_hash,
+                        },
+                    )
+                session = Session(bind=connection)
+                repository = DataRuleRepository(session)
+                persisted = repository.persist_bound_component_plan(
+                    component_binding_id=component_id,
+                    rule_version_id=rule_id,
+                    input_binding_id=input_binding["id"],
+                    output_binding_id=output_binding["id"],
+                    compiled=compiled,
+                )
+                service = PhysicalPlanPublicationService(
+                    repository,
+                    test_runner=ServerOwnedPhysicalPreflightRunner(
+                        artifact_store=None,
+                        datasource_manager=ReadOnlyPreflightManager(
+                            source_engine
+                        ),
+                    ),
+                )
+                validated = service.validate(persisted["id"], actor_id)
+                assert service.validate(persisted["id"], actor_id) == validated
+                tested = service.test(persisted["id"], actor_id)
+                assert service.test(persisted["id"], actor_id) == tested
+
+                drift_cases = (
+                    (
+                        "dataflow_dataset_bindings",
+                        input_binding["id"],
+                        "binding_hash",
+                        "f" * 64,
+                        input_binding_hash,
+                    ),
+                    (
+                        "dataflow_dataset_bindings",
+                        input_binding["id"],
+                        "data_source_uid",
+                        new_governance_uid(),
+                        data_source_uid,
+                    ),
+                    (
+                        "dataflow_dataset_bindings",
+                        input_binding["id"],
+                        "object_ref",
+                        f"{schema_name}.drifted_source",
+                        source_ref,
+                    ),
+                    (
+                        "dataflow_dataset_bindings",
+                        input_binding["id"],
+                        "dialect",
+                        "mysql" if dialect == "postgresql" else "postgresql",
+                        dialect,
+                    ),
+                    (
+                        "data_schema_snapshots",
+                        output_schema["id"],
+                        "schema_hash",
+                        "f" * 64,
+                        output_schema["schema_hash"],
+                    ),
+                    (
+                        "data_rule_versions",
+                        rule_id,
+                        "status",
+                        "revoked",
+                        "published",
+                    ),
+                    (
+                        "rule_execution_plans",
+                        persisted["id"],
+                        "compiler_version",
+                        "drifted-compiler",
+                        compiled["compiler_version"],
+                    ),
+                )
+                for table_name, row_id, column, drifted, canonical in drift_cases:
+                    connection.execute(
+                        text(
+                            f"UPDATE public.{table_name} SET {column} = :value "
+                            "WHERE id = CAST(:id AS uuid)"
+                        ),
+                        {"id": row_id, "value": drifted},
+                    )
+                    with pytest.raises(ValueError, match="drifted|not found"):
+                        service.publish(persisted["id"], actor_id)
+                    connection.execute(
+                        text(
+                            f"UPDATE public.{table_name} SET {column} = :value "
+                            "WHERE id = CAST(:id AS uuid)"
+                        ),
+                        {"id": row_id, "value": canonical},
+                    )
+                connection.execute(
+                    text(
+                        "UPDATE public.rule_test_evidence "
+                        "SET schema_hashes = '{}'::jsonb "
+                        "WHERE rule_execution_plan_id = CAST(:id AS uuid)"
+                    ),
+                    {"id": persisted["id"]},
+                )
+                with pytest.raises(ValueError, match="drifted"):
+                    service.publish(persisted["id"], actor_id)
+                connection.execute(
+                    text(
+                        "UPDATE public.rule_test_evidence te "
+                        "SET schema_hashes = p.schema_hashes "
+                        "FROM public.rule_execution_plans p "
+                        "WHERE te.rule_execution_plan_id = p.id "
+                        "AND p.id = CAST(:id AS uuid)"
+                    ),
+                    {"id": persisted["id"]},
+                )
+                if dialect == "postgresql":
+                    second_actor_id = new_governance_uid()
+                    second_component_id = new_governance_uid()
+                    connection.execute(
+                        text(
+                            "INSERT INTO public.users "
+                            "(id, username, display_name, password_hash, "
+                            "status) VALUES (CAST(:id AS uuid), :username, "
+                            "'Task7 Concurrent', 'not-a-login-secret', "
+                            "'active')"
+                        ),
+                        {
+                            "id": second_actor_id,
+                            "username": f"task7-{second_actor_id}",
+                        },
+                    )
+                    connection.execute(
+                        text(
+                            "INSERT INTO public.dataflow_component_bindings "
+                            "(id, dataflow_version_id, component_id, "
+                            "component_kind, rule_version_id, stage, order_no, "
+                            "idempotency, provenance) VALUES "
+                            "(CAST(:id AS uuid), CAST(:version_id AS uuid), "
+                            "'task7_publish_concurrent', 'rule.apply', "
+                            "CAST(:rule_id AS uuid), 'transform', 1, "
+                            "'{\"strategy\":\"upsert\","
+                            "\"key\":\"customer_id\"}'::jsonb, '{}'::jsonb)"
+                        ),
+                        {
+                            "id": second_component_id,
+                            "version_id": dataflow_version_id,
+                            "rule_id": rule_id,
+                        },
+                    )
+                    second_plan = repository.persist_bound_component_plan(
+                        component_binding_id=second_component_id,
+                        rule_version_id=rule_id,
+                        input_binding_id=input_binding["id"],
+                        output_binding_id=output_binding["id"],
+                        compiled=compiled,
+                    )
+                    service.validate(second_plan["id"], actor_id)
+                    service.test(second_plan["id"], actor_id)
+                    transaction.commit()
+
+                    def publish_in_session(
+                        plan_id: str,
+                        publishing_actor: str,
+                        delay: float = 0.0,
+                    ):
+                        if delay:
+                            time.sleep(delay)
+                        with platform_engine.begin() as worker_connection:
+                            worker_service = PhysicalPlanPublicationService(
+                                DataRuleRepository(
+                                    Session(bind=worker_connection)
+                                ),
+                                test_runner=None,
+                            )
+                            try:
+                                return (
+                                    "ok",
+                                    worker_service.publish(
+                                        plan_id, publishing_actor
+                                    ),
+                                )
+                            except ValueError as exc:
+                                return ("rejected", str(exc))
+
+                    with ThreadPoolExecutor(max_workers=2) as executor:
+                        same_actor = [
+                            executor.submit(
+                                publish_in_session,
+                                persisted["id"],
+                                actor_id,
+                            )
+                            for _ in range(2)
+                        ]
+                    same_actor_results = [
+                        future.result() for future in same_actor
+                    ]
+                    assert [item[0] for item in same_actor_results] == [
+                        "ok",
+                        "ok",
+                    ]
+                    assert (
+                        same_actor_results[0][1]
+                        == same_actor_results[1][1]
+                    )
+
+                    with ThreadPoolExecutor(max_workers=2) as executor:
+                        owner_future = executor.submit(
+                            publish_in_session,
+                            second_plan["id"],
+                            actor_id,
+                        )
+                        other_future = executor.submit(
+                            publish_in_session,
+                            second_plan["id"],
+                            second_actor_id,
+                            0.05,
+                        )
+                    assert owner_future.result()[0] == "ok"
+                    assert other_future.result()[0] == "rejected"
+
+                    with platform_engine.begin() as cleanup:
+                        plan_ids = [
+                            persisted["id"],
+                            second_plan["id"],
+                        ]
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM public.rule_publication_audits "
+                                "WHERE rule_execution_plan_id = "
+                                "ANY(CAST(:ids AS uuid[]))"
+                            ),
+                            {"ids": plan_ids},
+                        )
+                        for evidence_table in (
+                            "rule_test_evidence",
+                            "rule_compile_evidence",
+                        ):
+                            cleanup.execute(
+                                text(
+                                    f"DELETE FROM public.{evidence_table} "
+                                    "WHERE rule_execution_plan_id = "
+                                    "ANY(CAST(:ids AS uuid[]))"
+                                ),
+                                {"ids": plan_ids},
+                            )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM public.rule_execution_plans "
+                                "WHERE id = ANY(CAST(:ids AS uuid[]))"
+                            ),
+                            {"ids": plan_ids},
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM public.dataflow_dataset_bindings "
+                                "WHERE dataflow_deployment_id = "
+                                "CAST(:id AS uuid)"
+                            ),
+                            {"id": deployment_id},
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM "
+                                "public.dataflow_component_bindings "
+                                "WHERE id = ANY(CAST(:ids AS uuid[]))"
+                            ),
+                            {
+                                "ids": [
+                                    component_id,
+                                    second_component_id,
+                                ]
+                            },
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM public.dataflow_deployments "
+                                "WHERE id = CAST(:id AS uuid)"
+                            ),
+                            {"id": deployment_id},
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM public.dataflow_versions "
+                                "WHERE id = CAST(:id AS uuid)"
+                            ),
+                            {"id": dataflow_version_id},
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM "
+                                "public.rule_logical_test_evidence "
+                                "WHERE logical_plan_id IN (SELECT id FROM "
+                                "public.rule_logical_plans WHERE "
+                                "rule_version_id = CAST(:id AS uuid))"
+                            ),
+                            {"id": rule_id},
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM "
+                                "public.rule_logical_compile_evidence "
+                                "WHERE logical_plan_id IN (SELECT id FROM "
+                                "public.rule_logical_plans WHERE "
+                                "rule_version_id = CAST(:id AS uuid))"
+                            ),
+                            {"id": rule_id},
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM public.rule_logical_plans "
+                                "WHERE rule_version_id = CAST(:id AS uuid)"
+                            ),
+                            {"id": rule_id},
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM "
+                                "public.rule_validation_profiles "
+                                "WHERE rule_version_id = CAST(:id AS uuid)"
+                            ),
+                            {"id": rule_id},
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM public.data_rule_versions "
+                                "WHERE id = CAST(:id AS uuid)"
+                            ),
+                            {"id": rule_id},
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM public.data_rules "
+                                "WHERE rule_uid = CAST(:id AS uuid)"
+                            ),
+                            {"id": rule_uid},
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM public.data_schema_snapshots "
+                                "WHERE id = ANY(CAST(:ids AS uuid[]))"
+                            ),
+                            {
+                                "ids": [
+                                    input_schema["id"],
+                                    output_schema["id"],
+                                ]
+                            },
+                        )
+                        cleanup.execute(
+                            text(
+                                "DELETE FROM public.users "
+                                "WHERE id = ANY(CAST(:ids AS uuid[]))"
+                            ),
+                            {"ids": [actor_id, second_actor_id]},
+                        )
+                else:
+                    published = service.publish(persisted["id"], actor_id)
+                    assert (
+                        service.publish(persisted["id"], actor_id)
+                        == published
+                    )
+                    audit_count = connection.execute(
+                        text(
+                            "SELECT COUNT(*) FROM "
+                            "public.rule_publication_audits "
+                            "WHERE rule_execution_plan_id = "
+                            "CAST(:id AS uuid) AND action = 'published'"
+                        ),
+                        {"id": persisted["id"]},
+                    ).scalar_one()
+                    assert audit_count == 1
+            finally:
+                if transaction.is_active:
+                    transaction.rollback()
+    finally:
+        with source_engine.begin() as source:
+            source.execute(text(f"DROP TABLE IF EXISTS {target_table}"))
+            source.execute(text(f"DROP TABLE IF EXISTS {source_table}"))
+        source_engine.dispose()
+        platform_engine.dispose()

+ 108 - 29
tests/integration/test_rule_publication_lifecycle.py

@@ -67,10 +67,14 @@ def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
         memory_limit_bytes=256 * 1024 * 1024,
         max_ttl_seconds=3600,
     )
-    fields = [
+    input_fields = [
         {"name": "name", "type": "string", "nullable": True},
         {"name": "mobile", "type": "string", "nullable": True},
     ]
+    output_fields = [
+        *input_fields,
+        {"name": "name_copy", "type": "string", "nullable": True},
+    ]
     sample = store.write(
         pl.DataFrame(
             {
@@ -80,7 +84,19 @@ def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
         ),
         new_governance_uid(),
         600,
-        schema_fields=fields,
+        schema_fields=input_fields,
+    )
+    golden = store.write(
+        pl.DataFrame(
+            {
+                "name": ["Alice", "Carol"],
+                "mobile": ["13800138000", "13900139000"],
+                "name_copy": ["Alice", "Carol"],
+            }
+        ),
+        new_governance_uid(),
+        600,
+        schema_fields=output_fields,
     )
     engine = create_engine(database_url)
     with engine.connect() as connection:
@@ -91,31 +107,69 @@ def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
                     "SELECT id::text FROM public.users "
                     "WHERE status = 'active' ORDER BY created_at LIMIT 1"
                 )
-            ).scalar_one()
+            ).scalar_one_or_none()
+            if actor is None:
+                actor = new_governance_uid()
+                connection.execute(
+                    text(
+                        "INSERT INTO public.users "
+                        "(id, username, display_name, password_hash, status) "
+                        "VALUES (CAST(:id AS uuid), :username, "
+                        "'Task7 Integration', 'not-a-login-secret', 'active')"
+                    ),
+                    {
+                        "id": actor,
+                        "username": f"task7-{actor[:8]}",
+                    },
+                )
             session = Session(bind=connection)
             repository = DataRuleRepository(session)
-            snapshot_value = {
-                    "schema_ref": "bd:rule-publication:integration",
+            input_snapshot_value = {
+                    "schema_ref": "bd:rule-publication:input",
                     "source_revision": "integration:1",
-                    "fields": fields,
+                    "fields": input_fields,
                 }
-            snapshot_value["schema_hash"] = canonical_schema_hash(
-                snapshot_value["fields"]
+            input_snapshot_value["schema_hash"] = canonical_schema_hash(
+                input_snapshot_value["fields"]
             )
-            snapshot = repository.persist_schema_snapshot(
-                snapshot=snapshot_value
+            input_snapshot = repository.persist_schema_snapshot(
+                snapshot=input_snapshot_value
+            )
+            output_snapshot_value = {
+                "schema_ref": "bd:rule-publication:output",
+                "source_revision": "integration:1",
+                "fields": output_fields,
+            }
+            output_snapshot_value["schema_hash"] = canonical_schema_hash(
+                output_snapshot_value["fields"]
+            )
+            output_snapshot = repository.persist_schema_snapshot(
+                snapshot=output_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"],
+                "input_schema_snapshot_id": input_snapshot["id"],
+                "input_schema_hash": input_snapshot["schema_hash"],
+                "input_fields": input_snapshot["fields"],
+                "output_schema_snapshot_id": output_snapshot["id"],
+                "output_schema_hash": output_snapshot["schema_hash"],
+                "output_fields": output_snapshot["fields"],
+                "input_sample_artifact_ref": sample["artifact_ref"],
+                "input_sample_artifact_digest": sample["digest"],
+                "golden_output_artifact_ref": golden["artifact_ref"],
+                "golden_output_artifact_digest": golden["digest"],
             }
             spec = valid_rule_spec()
-            spec["input_schema_ref"] = snapshot["schema_ref"]
-            spec["output_schema_ref"] = snapshot["schema_ref"]
+            spec["input_schema_ref"] = input_snapshot["schema_ref"]
+            spec["output_schema_ref"] = output_snapshot["schema_ref"]
             spec["steps"][1]["on_failure"] = "quarantine"
+            spec["steps"].append(
+                {
+                    "id": "copy_name",
+                    "op": "derive",
+                    "target": "name_copy",
+                    "expression": "name",
+                }
+            )
             candidate = {
                 "schema_version": "1.0",
                 "candidate_type": "rule",
@@ -179,19 +233,19 @@ def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
             )
             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",
-                )
+            assert 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",
+            ) == draft
 
             compiled = service.validate(draft["id"], actor)
             assert compiled["plan_status"] == "compiled"
+            assert service.validate(draft["id"], actor) == compiled
             logical_plan = (
                 connection.execute(
                     text(
@@ -214,7 +268,7 @@ def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
                     "sample_artifact": {
                         "artifact_ref": sample["artifact_ref"],
                         "digest": sample["digest"],
-                        "schema_fields": fields,
+                        "schema_fields": input_fields,
                     },
                 }
             )
@@ -223,7 +277,7 @@ def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
                 version_id=draft["id"],
                 plan_id=compiled["plan_id"],
             )
-            tampered_sample["sample_artifact_digest"] = "f" * 64
+            tampered_sample["input_sample_artifact_digest"] = "f" * 64
             with pytest.raises(ValueError, match="drifted"):
                 ServerOwnedLogicalDryRunRunner(store).run(tampered_sample)
             tested = service.test(
@@ -232,6 +286,30 @@ def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
             assert tested["version_status"] == "validated"
             assert tested["test_evidence"]["counts"]["rows_quarantined"] == 1
             assert tested["test_evidence"]["counts"]["rows_rejected"] == 0
+            connection.execute(
+                text(
+                    "UPDATE public.data_schema_snapshots "
+                    "SET schema_hash = :drifted_hash "
+                    "WHERE id = CAST(:id AS uuid)"
+                ),
+                {
+                    "id": output_snapshot["id"],
+                    "drifted_hash": "f" * 64,
+                },
+            )
+            with pytest.raises(ValueError, match="drifted"):
+                service.publish(draft["id"], actor)
+            connection.execute(
+                text(
+                    "UPDATE public.data_schema_snapshots "
+                    "SET schema_hash = :schema_hash "
+                    "WHERE id = CAST(:id AS uuid)"
+                ),
+                {
+                    "id": output_snapshot["id"],
+                    "schema_hash": output_snapshot["schema_hash"],
+                },
+            )
             published = service.publish(draft["id"], actor)
             assert published["status"] == "published"
             assert published["plan_status"] == "published"
@@ -259,3 +337,4 @@ def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
             transaction.rollback()
             engine.dispose()
             store.delete(sample["artifact_ref"])
+            store.delete(golden["artifact_ref"])

+ 93 - 0
tests/integration/test_rule_publication_migration_upgrade.py

@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from contextlib import suppress
+
+import pytest
+from sqlalchemy import create_engine, text
+
+from app.core.common.identifiers import new_governance_uid
+from tests.integration.test_rule_artifact_migration_upgrade import (
+    _compose_value,
+    _upgrade,
+)
+
+
+def test_legacy_published_rule_blocks_real_190_to_200_upgrade():
+    platform_user = _compose_value(
+        r"\n  postgres:.*?POSTGRES_USER:\s*([^\s]+)"
+    )
+    platform_password = _compose_value(
+        r"\n  postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
+    )
+    platform_port = _compose_value(r'"(15432):5432"')
+    admin_url = (
+        f"postgresql+psycopg2://{platform_user}:{platform_password}"
+        f"@127.0.0.1:{platform_port}/postgres"
+    )
+    database_name = (
+        f"task7_legacy_{new_governance_uid().replace('-', '')}"
+    )
+    database_url = (
+        f"postgresql+psycopg2://{platform_user}:{platform_password}"
+        f"@127.0.0.1:{platform_port}/{database_name}"
+    )
+    admin = create_engine(admin_url, isolation_level="AUTOCOMMIT")
+    engine = None
+    try:
+        with admin.connect() as connection:
+            connection.execute(text(f'CREATE DATABASE "{database_name}"'))
+        _upgrade(database_url, "20260723_190")
+        engine = create_engine(database_url, pool_pre_ping=True)
+        rule_uid = new_governance_uid()
+        with engine.begin() as connection:
+            connection.execute(
+                text(
+                    "INSERT INTO public.data_rules "
+                    "(id, rule_uid, name, category, status) VALUES "
+                    "(CAST(:id AS uuid), CAST(:rule_uid AS uuid), "
+                    "'legacy published', 'legacy', 'active')"
+                ),
+                {"id": new_governance_uid(), "rule_uid": rule_uid},
+            )
+            connection.execute(
+                text(
+                    "INSERT INTO public.data_rule_versions "
+                    "(id, rule_uid, version_no, source_text, rule_spec, "
+                    "spec_hash, status) VALUES "
+                    "(CAST(:id AS uuid), CAST(:rule_uid AS uuid), 1, "
+                    "'legacy', '{}'::jsonb, :spec_hash, 'published')"
+                ),
+                {
+                    "id": new_governance_uid(),
+                    "rule_uid": rule_uid,
+                    "spec_hash": "a" * 64,
+                },
+            )
+        engine.dispose()
+        engine = None
+
+        with pytest.raises(Exception, match="legacy published"):
+            _upgrade(database_url, "20260723_200")
+
+        engine = create_engine(database_url, pool_pre_ping=True)
+        with engine.connect() as connection:
+            assert connection.execute(
+                text("SELECT version_num FROM alembic_version")
+            ).scalar_one() == "20260723_190"
+    finally:
+        if engine is not None:
+            engine.dispose()
+        with suppress(Exception), admin.connect() as connection:
+            connection.execute(
+                text(
+                    "SELECT pg_terminate_backend(pid) "
+                    "FROM pg_stat_activity "
+                    "WHERE datname = :database_name "
+                    "AND pid <> pg_backend_pid()"
+                ),
+                {"database_name": database_name},
+            )
+            connection.execute(
+                text(f'DROP DATABASE IF EXISTS "{database_name}"')
+            )
+        admin.dispose()

+ 3 - 0
tests/runner/test_rule_evidence.py

@@ -38,6 +38,9 @@ class Repository:
             "plan_hash": PLAN_HASH,
             "plan_status": "published",
             "rule_status": "published",
+            "publication_audit_trusted": True,
+            "logical_evidence_trusted": True,
+            "physical_evidence_trusted": True,
             "component_kind": "quality.check",
             "binding_idempotency": None,
         }

+ 7 - 0
tests/runner/test_rule_polars.py

@@ -724,12 +724,19 @@ def test_rule_executor_attests_polars_canonical_hashes_and_forwards_correlation(
             "input_schema_snapshot_id"
         ],
         "canonical_input_schema_hash": plan["input_schema_hash"],
+        "canonical_input_binding_hash": plan["input_binding_hash"],
+        "canonical_input_object_kind": "parquet_artifact",
         "canonical_output_schema_snapshot_id": plan[
             "output_schema_snapshot_id"
         ],
         "canonical_output_schema_hash": plan["output_schema_hash"],
+        "canonical_output_binding_hash": plan["output_binding_hash"],
+        "canonical_output_object_kind": "parquet_artifact",
         "plan_status": "published",
         "rule_status": "published",
+        "publication_audit_trusted": True,
+        "logical_evidence_trusted": True,
+        "physical_evidence_trusted": True,
         "component_kind": "rule.apply",
         "binding_idempotency": node["idempotency"],
     }

+ 6 - 0
tests/runner/test_rule_sql.py

@@ -243,6 +243,9 @@ def test_rule_executor_matches_node_idempotency_to_persisted_component():
                 "plan_hash": plan_hash,
                 "plan_status": "published",
                 "rule_status": "published",
+                "publication_audit_trusted": True,
+                "logical_evidence_trusted": True,
+                "physical_evidence_trusted": True,
                 "component_kind": "rule.apply",
                 "binding_idempotency": {
                     "strategy": "upsert",
@@ -298,6 +301,9 @@ def test_rule_executor_rechecks_canonical_rule_schema_and_compiler_attestations(
             "canonical_output_schema_hash": plan["output_schema_hash"],
             "plan_status": "published",
             "rule_status": "published",
+            "publication_audit_trusted": True,
+            "logical_evidence_trusted": True,
+            "physical_evidence_trusted": True,
             "component_kind": "rule.apply",
             "binding_idempotency": node["idempotency"],
         }

+ 6 - 0
tests/runner/test_rules.py

@@ -74,6 +74,9 @@ def published_record(node, **overrides):
         "plan_hash": PLAN_HASH,
         "plan_status": "published",
         "rule_status": "published",
+        "publication_audit_trusted": True,
+        "logical_evidence_trusted": True,
+        "physical_evidence_trusted": True,
         "component_kind": node["type"],
         "binding_idempotency": node.get("idempotency"),
     }
@@ -115,6 +118,9 @@ def test_rule_executor_loads_only_published_plan_by_fixed_identifiers():
         {"plan_status": "revoked"},
         {"rule_status": "deprecated"},
         {"plan_hash": "b" * 64},
+        {"publication_audit_trusted": False},
+        {"logical_evidence_trusted": False},
+        {"physical_evidence_trusted": False},
     ],
 )
 def test_rule_executor_fails_closed_for_missing_revoked_or_mismatched_plan(record):

+ 113 - 6
tests/test_data_rule_api.py

@@ -2,6 +2,8 @@ from __future__ import annotations
 
 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 app.core.system.tokens import decode_access_token, issue_access_token
@@ -60,11 +62,16 @@ class FakeRuleRepository:
     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,
+            "input_schema_snapshot_id": new_governance_uid(),
+            "input_schema_hash": "c" * 64,
+            "input_fields": [{"name": "mobile", "type": "string"}],
+            "output_schema_snapshot_id": new_governance_uid(),
+            "output_schema_hash": "d" * 64,
+            "output_fields": [{"name": "mobile", "type": "string"}],
+            "input_sample_artifact_ref": "minio://trusted/input.parquet",
+            "input_sample_artifact_digest": "e" * 64,
+            "golden_output_artifact_ref": None,
+            "golden_output_artifact_digest": None,
         }
 
     def publish_rule_version(self, **kwargs):
@@ -267,6 +274,9 @@ def test_rule_capabilities_and_validation_are_registered_and_governed(monkeypatc
     app = create_app()
     _use_token_identity(monkeypatch)
     app.config["TESTING"] = True
+    app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
+        "dedicated-test-receipt-secret-with-entropy"
+    )
     client = app.test_client()
 
     response = client.get("/api/rules/capabilities", headers=_headers(app, "viewer"))
@@ -303,6 +313,9 @@ def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch)
     app = create_app()
     _use_token_identity(monkeypatch)
     app.config["TESTING"] = True
+    app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
+        "dedicated-test-receipt-secret-with-entropy"
+    )
     agent = FakeAuthoringAgent()
     app.extensions["data_rule_authoring_agent"] = agent
     repository = FakeRuleRepository()
@@ -314,7 +327,14 @@ def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch)
         json={
             "source_text": "手机号去空格后必须为11位数字",
             "authoring_surface": "data_standard",
-            "context": {"input_schema_ref": "bd:customer:v7"},
+            "context": {
+                "input_schema_snapshot_id": new_governance_uid(),
+                "output_schema_snapshot_id": new_governance_uid(),
+                "input_sample_artifact_ref": (
+                    "minio://trusted/input.parquet"
+                ),
+                "golden_output_artifact_ref": None,
+            },
         },
         headers=_headers(app, "editor"),
     )
@@ -328,6 +348,73 @@ def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch)
     assert repository.calls[1][0] == "record_generation_run"
 
 
+def test_rule_interpret_and_validate_reject_unknown_fields(monkeypatch):
+    from app import create_app
+
+    app = create_app()
+    _use_token_identity(monkeypatch)
+    app.config["TESTING"] = True
+    app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
+        "dedicated-test-receipt-secret-with-entropy"
+    )
+    repository = FakeRuleRepository()
+    app.extensions["data_rule_repository"] = repository
+    app.extensions["data_rule_authoring_agent"] = FakeAuthoringAgent()
+    client = app.test_client()
+    headers = _headers(app, "editor")
+
+    interpreted = client.post(
+        "/api/rules/interpret",
+        json={
+            "source_text": "手机号必须为11位数字",
+            "authoring_surface": "data_standard",
+            "context": {},
+            "status": "published",
+        },
+        headers=headers,
+    )
+    validated = client.post(
+        "/api/rules/validate",
+        json={
+            "asset_type": "rule",
+            "spec": valid_rule_spec(),
+            "evidence": {"status": "success"},
+        },
+        headers=headers,
+    )
+
+    assert interpreted.status_code == 400
+    assert validated.status_code == 400
+    assert repository.calls == []
+
+
+def test_published_rule_catalog_uses_canonical_closed_contract(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()
+    headers = _headers(app, "viewer")
+
+    response = client.get(
+        "/api/rules/catalog?query=mobile&limit=10",
+        headers=headers,
+    )
+    rejected = client.get(
+        "/api/rules/catalog?query=mobile&status=published",
+        headers=headers,
+    )
+
+    assert response.status_code == 200
+    assert response.get_json()["data"] == {"items": []}
+    assert rejected.status_code == 400
+
+
 def test_production_line_resolve_preview_expands_standard_without_writing(monkeypatch):
     from app import create_app
 
@@ -497,6 +584,26 @@ def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
     assert response.status_code == 400
 
 
+def test_generation_receipt_signer_requires_dedicated_secret(monkeypatch):
+    from app import create_app
+    from app.api.data_rules.routes import _receipt_signer
+
+    monkeypatch.delenv("RULE_GENERATION_RECEIPT_SECRET", raising=False)
+    app = create_app()
+    app.config["RULE_GENERATION_RECEIPT_SECRET"] = None
+    with app.app_context(), pytest.raises(
+        RuntimeError, match="receipt secret"
+    ):
+        _receipt_signer()
+
+    app.config["RULE_GENERATION_RECEIPT_SECRET"] = (
+        "dedicated-test-receipt-secret-with-entropy"
+    )
+    with app.app_context():
+        signer = _receipt_signer()
+    assert signer is not None
+
+
 def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(monkeypatch):
     from app import create_app
 

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