Enforce quality gates on output publication

This commit is contained in:
2026-08-04 11:33:11 +02:00
parent 075d2fc087
commit 2d93ad07d2
4 changed files with 221 additions and 23 deletions
+17 -5
View File
@@ -1,10 +1,10 @@
# Datasource Stage Quality Policy # Datasource Quality Policy
Datasource quality policy is a deterministic JSON contract stored in Datasource quality policy is a deterministic JSON contract stored in
`governance.quality_policy`. A tabular stage inherits the current target `governance.quality_policy`. A tabular stage or producer publication inherits
Datasource policy unless the stage has its own governed definition. Validation the current target Datasource policy unless it supplies its own governed
runs before the stage is stored, but a failed stage remains available for definition. Stage validation runs before the stage is stored, but a failed
inspection and correction through a new stage. stage remains available for inspection and correction through a new stage.
## Contract ## Contract
@@ -78,6 +78,18 @@ contain only affected counts and at most 25 one-based row numbers; they never
copy field values. Promotion copies this validation object into the immutable copy field values. Promotion copies this validation object into the immutable
materialization provenance and records the policy hash in the audit event. materialization provenance and records the policy hash in the audit event.
Producer publication uses the same gate before any catalogue target,
materialization, or publication record is persisted. A rejected output has no
partial catalogue effect. Successful output materializations retain the exact
validation result, policy version/hash, and schema classification in
`provenance.publication_validation`; the publication record retains the same
evidence for operational inspection. Dataflow, Workflow, and Reporting can
therefore consume an immutable output reference without re-running a possibly
changed quality policy. Publication also emits a transactional
`datasource.publication.published` audit/platform event; an enabled Audit
module stores it in the durable outbox, while reduced installations deliver it
through Core after commit.
Approval authority, approval expiry, and retention/deletion execution remain Approval authority, approval expiry, and retention/deletion execution remain
separate work under `govoplan-datasources#2`. Until those contracts are added, separate work under `govoplan-datasources#2`. Until those contracts are added,
no JSON flag is treated as an approval and no stage is deleted automatically. no JSON flag is treated as an approval and no stage is deleted automatically.
+5 -4
View File
@@ -406,15 +406,16 @@ manifest = ModuleManifest(
), ),
DocumentationTopic( DocumentationTopic(
id="datasources.quality-gates", id="datasources.quality-gates",
title="Validate a Datasource stage", title="Validate staged and produced Datasource revisions",
summary="Apply deterministic quality rules and schema-change policy before staged data can become consumable.", summary="Apply deterministic quality rules and schema-change policy before staged or produced data can become consumable.",
body=( body=(
"Tabular stages evaluate configured row count, required-field, field-shape, nullability, uniqueness, numeric-range, and bounded " "Tabular stages evaluate configured row count, required-field, field-shape, nullability, uniqueness, numeric-range, and bounded "
"referential-set rules. Errors keep the stage inspectable but block promotion; warnings stay visible and permit an explicit " "referential-set rules. Errors keep the stage inspectable but block promotion; warnings stay visible and permit an explicit "
"promotion. Updates compare the detected schema with the current target and classify each change as compatible, warning, or " "promotion. Updates compare the detected schema with the current target and classify each change as compatible, warning, or "
"breaking. Diagnostics expose counts and bounded row numbers, never field values. The policy version and hash, diagnostics, and " "breaking. Diagnostics expose counts and bounded row numbers, never field values. The policy version and hash, diagnostics, and "
"schema diff are copied into immutable materialization provenance when promotion succeeds. Approval and retention execution are " "schema diff are copied into immutable materialization provenance when promotion succeeds. Producer publication applies the same "
"not inferred from arbitrary JSON flags and remain separate governed lifecycle work." "gate before any catalogue effect, retains validation evidence on the immutable output revision, and emits a transactional terminal event. Approval and retention execution "
"are not inferred from arbitrary JSON flags and remain separate governed lifecycle work."
), ),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
+96 -14
View File
@@ -10,6 +10,7 @@ from typing import Any, cast
from sqlalchemy import exists, func, or_, select from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, has_scope from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.datasources import ( from govoplan_core.core.datasources import (
DatasourceAccessError, DatasourceAccessError,
@@ -108,12 +109,37 @@ class SqlDatasourceProvider:
return existing return existing
actor_id = _actor_id(api_principal) actor_id = _actor_id(api_principal)
target, governance, baseline_schema = _publication_validation_context(
db,
tenant_id=api_principal.tenant_id,
request=request,
)
validation = validate_stage(
rows=prepared.rows,
schema=prepared.schema,
quality_policy=governance.quality_policy,
baseline_schema=baseline_schema,
)
if validation["valid"] is not True:
errors = validation.get("errors", [])
error_codes = ", ".join(
str(item.get("code") or "validation.error")
for item in errors
if isinstance(item, Mapping)
)
suffix = f" ({error_codes})" if error_codes else ""
raise DatasourceValidationError(
"Published output failed governed quality or schema validation"
f"{suffix}."
)
datasource = _publication_target( datasource = _publication_target(
db, db,
tenant_id=api_principal.tenant_id, tenant_id=api_principal.tenant_id,
actor_id=actor_id, actor_id=actor_id,
request=request, request=request,
prepared=prepared, prepared=prepared,
target=target,
governance=governance,
) )
materialization = _append_materialization( materialization = _append_materialization(
db, db,
@@ -126,7 +152,10 @@ class SqlDatasourceProvider:
frozen=request.freeze, frozen=request.freeze,
frozen_label=request.frozen_label, frozen_label=request.frozen_label,
source_timestamp=request.source_timestamp, source_timestamp=request.source_timestamp,
provenance=_publication_provenance(request, prepared), provenance={
**_publication_provenance(request, prepared),
"publication_validation": validation,
},
metadata=dict(request.metadata), metadata=dict(request.metadata),
set_current=request.set_current, set_current=request.set_current,
) )
@@ -139,6 +168,28 @@ class SqlDatasourceProvider:
request=request, request=request,
prepared=prepared, prepared=prepared,
) )
audit_event(
db,
tenant_id=api_principal.tenant_id,
user_id=getattr(api_principal.user, "id", None)
or api_principal.account_id,
api_key_id=api_principal.api_key_id,
action="datasource.publication.published",
object_type="datasource_publication",
object_id=publication.id,
details={
"producer_module": prepared.producer_module,
"producer_run_ref": prepared.producer_run_ref,
"datasource_ref": _datasource_ref(datasource.id),
"materialization_ref": _materialization_ref(materialization.id),
"fingerprint": prepared.fingerprint,
"row_count": len(prepared.rows),
"policy_hash": validation["policy_hash"],
"schema_classification": validation["schema_change"][
"classification"
],
},
)
return DatasourcePublicationResult( return DatasourcePublicationResult(
ref=_publication_ref(publication.id), ref=_publication_ref(publication.id),
status=publication.status, status=publication.status,
@@ -1607,19 +1658,13 @@ def _publication_target(
actor_id: str | None, actor_id: str | None,
request: DatasourcePublicationRequest, request: DatasourcePublicationRequest,
prepared: _PreparedPublication, prepared: _PreparedPublication,
target: DatasourceRecord | None,
governance: DatasourceGovernance,
) -> DatasourceRecord: ) -> DatasourceRecord:
if request.target_datasource_ref: if target is not None:
datasource = _required_datasource( datasource = target
session,
tenant_id=tenant_id,
datasource_ref=request.target_datasource_ref,
)
if datasource.mode == "live" or datasource.shape != "tabular":
raise DatasourceValidationError(
"Produced rows require a static or cached tabular datasource."
)
if request.governance is not None: if request.governance is not None:
_apply_datasource_governance(datasource, request.governance) _apply_datasource_governance(datasource, governance)
datasource.updated_by = actor_id datasource.updated_by = actor_id
return datasource return datasource
name = str(request.name or "").strip() name = str(request.name or "").strip()
@@ -1665,14 +1710,47 @@ def _publication_target(
) )
_apply_datasource_governance( _apply_datasource_governance(
datasource, datasource,
request.governance governance,
or _default_governance(mode="static", provider_ref=None),
) )
session.add(datasource) session.add(datasource)
session.flush() session.flush()
return datasource return datasource
def _publication_validation_context(
session: Session,
*,
tenant_id: str,
request: DatasourcePublicationRequest,
) -> tuple[
DatasourceRecord | None,
DatasourceGovernance,
tuple[DatasourceField, ...] | None,
]:
if not request.target_datasource_ref:
return (
None,
request.governance
or _default_governance(mode="static", provider_ref=None),
None,
)
target = _required_datasource(
session,
tenant_id=tenant_id,
datasource_ref=request.target_datasource_ref,
for_update=True,
)
if target.mode == "live" or target.shape != "tabular":
raise DatasourceValidationError(
"Produced rows require a static or cached tabular datasource."
)
return (
target,
request.governance or _datasource_governance(target),
_fields(target.schema_) if target.current_materialization_id else None,
)
def _publication_provenance( def _publication_provenance(
request: DatasourcePublicationRequest, request: DatasourcePublicationRequest,
prepared: _PreparedPublication, prepared: _PreparedPublication,
@@ -1710,6 +1788,10 @@ def _create_publication_record(
"row_count": len(prepared.rows), "row_count": len(prepared.rows),
"set_current": request.set_current, "set_current": request.set_current,
"frozen": request.freeze, "frozen": request.freeze,
"validation": materialization.provenance_.get(
"publication_validation",
{},
),
}, },
created_by=actor_id, created_by=actor_id,
) )
+103
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import sessionmaker
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.datasources import ( from govoplan_core.core.datasources import (
CAPABILITY_DATASOURCE_ORIGINS, CAPABILITY_DATASOURCE_ORIGINS,
DatasourceAccessError, DatasourceAccessError,
@@ -154,6 +155,7 @@ class DatasourceLifecycleTests(unittest.TestCase):
DatasourceMaterializationRecord.__table__, DatasourceMaterializationRecord.__table__,
DatasourceStageRecord.__table__, DatasourceStageRecord.__table__,
DatasourcePublicationRecord.__table__, DatasourcePublicationRecord.__table__,
ChangeSequenceEntry.__table__,
], ],
) )
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
@@ -175,6 +177,7 @@ class DatasourceLifecycleTests(unittest.TestCase):
DatasourcePayloadRecord.__table__, DatasourcePayloadRecord.__table__,
DatasourceGovernanceReferenceRecord.__table__, DatasourceGovernanceReferenceRecord.__table__,
DatasourceRecord.__table__, DatasourceRecord.__table__,
ChangeSequenceEntry.__table__,
], ],
) )
self.engine.dispose() self.engine.dispose()
@@ -651,6 +654,106 @@ class DatasourceLifecycleTests(unittest.TestCase):
), ),
) )
def test_publication_enforces_quality_before_persisting_any_effect(self) -> None:
producer = principal(scopes=(SOURCE_WRITE_SCOPE,))
blocked = DatasourcePublicationRequest(
producer_module="dataflow",
producer_run_ref="dataflow-run:blocked",
idempotency_key="blocked-output",
name="Blocked output",
source_name="blocked_output",
rows=({"id": 1},),
governance=DatasourceGovernance(
quality_policy={
"version": "minimum-two-v1",
"rules": [
{
"id": "minimum-two",
"type": "row_count",
"minimum": 2,
}
],
},
),
)
with self.assertRaisesRegex(
DatasourceValidationError,
r"quality or schema validation \(quality\.row_count\)",
):
self.provider.publish_rows(self.session, producer, request=blocked)
self.assertEqual(0, self.session.query(DatasourceRecord).count())
self.assertEqual(
0,
self.session.query(DatasourceMaterializationRecord).count(),
)
self.assertEqual(
0,
self.session.query(DatasourcePublicationRecord).count(),
)
def test_publication_preserves_quality_and_schema_evidence(self) -> None:
producer = principal(scopes=(SOURCE_WRITE_SCOPE,))
first = self.provider.publish_rows(
self.session,
producer,
request=DatasourcePublicationRequest(
producer_module="dataflow",
producer_run_ref="dataflow-run:first",
idempotency_key="governed-output-first",
name="Governed output",
source_name="governed_output",
rows=({"id": 1},),
governance=DatasourceGovernance(
quality_policy={
"version": "governed-output-v1",
"rules": [
{
"id": "unique-id",
"type": "unique",
"fields": ["id"],
},
],
"schema_policy": {"field_added_required": "warning"},
},
),
),
)
second = self.provider.publish_rows(
self.session,
producer,
request=DatasourcePublicationRequest(
producer_module="dataflow",
producer_run_ref="dataflow-run:second",
idempotency_key="governed-output-second",
target_datasource_ref=first.datasource.ref,
rows=({"id": 2, "result": "match"},),
),
)
validation = second.materialization.provenance["publication_validation"]
self.assertTrue(validation["valid"])
self.assertEqual("governed-output-v1", validation["policy_version"])
self.assertEqual(
"warning",
validation["schema_change"]["classification"],
)
self.assertEqual(
"schema.field_added_required",
validation["warnings"][0]["code"],
)
record = self.session.get(
DatasourcePublicationRecord,
second.ref.removeprefix("publication:"),
)
self.assertIsNotNone(record)
assert record is not None
self.assertEqual(
validation["policy_hash"],
record.details_["validation"]["policy_hash"],
)
def test_publication_requires_source_write_scope(self) -> None: def test_publication_requires_source_write_scope(self) -> None:
with self.assertRaises(DatasourceAccessError): with self.assertRaises(DatasourceAccessError):
self.provider.publish_rows( self.provider.publish_rows(