diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ce8b4c8..8efd7a5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,6 +189,9 @@ jobs: - name: Validate metadata fabric Active Metadata binding reconciliation evidence run: python -m data_agent.metadata_fabric_active_metadata_binding_reconciliation validate + - name: Validate metadata fabric durable Active Metadata promotion evidence + run: python -m data_agent.metadata_fabric_durable_active_metadata_promotion validate + - name: Validate Active Metadata consumer deployment boundary run: python -m data_agent.active_metadata_consumer_deployment validate @@ -277,6 +280,7 @@ jobs: data_agent/test_metadata_fabric_active_metadata_scheduler_delivery.py \ data_agent/test_metadata_fabric_active_metadata_projection_execution.py \ data_agent/test_metadata_fabric_active_metadata_binding_reconciliation.py \ + data_agent/test_metadata_fabric_durable_active_metadata_promotion.py \ data_agent/test_metadata_fabric_lineage_delivery.py \ data_agent/test_metadata_fabric_provider_identity.py \ data_agent/test_metadata_fabric_gravitino_identity.py \ diff --git a/config/metadata-fabric-durable-active-metadata-promotion.local.yaml b/config/metadata-fabric-durable-active-metadata-promotion.local.yaml new file mode 100644 index 00000000..b1ca49ba --- /dev/null +++ b/config/metadata-fabric-durable-active-metadata-promotion.local.yaml @@ -0,0 +1,45 @@ +schema: gda.durable_active_metadata_promotion_profile.v1 +environment: local_docker_desktop + +dependencies: + m319_evidence_path: docs/evidence/metadata-fabric-active-metadata-binding-reconciliation-2026-07-30.json + m319_evidence_sha256: e6d0e3ac4e052029dad0c18d0804626a8af61554a54081c37d8cc9a80c55cd33 + jdbc_restart_profile_path: config/metadata-fabric-gravitino-jdbc-restart.local.yaml + jdbc_restart_evidence_path: docs/evidence/metadata-fabric-gravitino-jdbc-restart-2026-07-29.json + jdbc_restart_evidence_fingerprint: 34792bb47ad71041a87adeb644439bf9b6aa3f4855cdc98782d6e3b4282bf1aa + +target: + metalake: gda_chongqing_m3_20 + catalog: lakehouse + schema: cultural_heritage + table: cultural_districts + catalog_type: RELATIONAL + catalog_provider: lakehouse-iceberg + catalog_backend: jdbc + uri: jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg + warehouse: file:///var/lib/gravitino/warehouse + jdbc_driver: org.postgresql.Driver + +identity: + service_admin: gda-persistence-admin + user: gda-active-metadata-promoter + role: gda-cultural-district-projector + material_delivery: runtime_generated_ephemeral_kubernetes_object + +authorization: + policy_version_ref: gda://metadata-authorization-local/policy/durable-metadata-promotion-v1 + evaluator_subject: workload:durable-metadata-policy-evaluator + approver_subject: human:metadata-platform-owner + approval_reason: approved local durable Active Metadata promotion rehearsal + +claims: + binding_schema_changed: false + durable_candidate_persisted_to_gda_control: false + protected_workload_identity_verified: false + provider_minimum_privilege_verified: false + durable_catalog_verified: false + production_object_store_verified: false + oidc_verified: false + tls_verified: false + production_ingestion_verified: false + production_ready: false diff --git a/data_agent/metadata_fabric_durable_active_metadata_promotion.py b/data_agent/metadata_fabric_durable_active_metadata_promotion.py new file mode 100644 index 00000000..09c56295 --- /dev/null +++ b/data_agent/metadata_fabric_durable_active_metadata_promotion.py @@ -0,0 +1,1704 @@ +"""Promote a real Active Metadata projection into a restart-continuous catalog. + +M3-20 consumes the checked M3-19 Chongqing binding evidence, verifies the +retained OpenMetadata projection without writing it, and creates a new +Gravitino projection through a schema-bounded principal. The target uses the +isolated JDBC/PVC runtime already proven by M3-8. The exact projection must be +a no-op both immediately and on the first request after ordered PostgreSQL and +Gravitino restarts. + +The resulting promotion candidate binds the logical provider refs to the +observed cluster, namespace, service, StatefulSet and PVC identities. It does +not alter the M3-19 binding schema or ledger and does not establish production +durability, protected identity, production ingestion or terminal run success. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import secrets +from collections.abc import Mapping +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, Literal +from urllib.parse import quote +from uuid import UUID, uuid5 + +import yaml +from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator + +from . import metadata_fabric_active_metadata_binding_reconciliation as m319 +from . import metadata_fabric_active_metadata_projection_execution as execution +from . import metadata_fabric_bridge as bridge +from . import metadata_fabric_gravitino_identity as identity +from . import metadata_fabric_gravitino_jdbc_restart as jdbc_restart +from . import metadata_fabric_ingestion_replay as replay +from . import metadata_fabric_provider_metrics as provider_metrics +from .platform_authorization import ( + build_approval_artifact, + build_policy_decision_artifact, + validate_run_authorization_evidence, +) +from .platform_contracts import ( + ApprovalRecord, + Artifact, + ArtifactRole, + PlatformRun, + PolicyDecision, + RunPolicyReferences, + SubjectContext, + canonical_json_bytes, + canonical_json_fingerprint, +) +from .spatial_dataset_bundle import validate_shapefile_bundle_inventory + +PROFILE_SCHEMA = "gda.durable_active_metadata_promotion_profile.v1" +PLAN_SCHEMA = "gda.durable_active_metadata_projection_plan.v1" +PROMOTION_SCHEMA = "gda.runtime_bound_metadata_promotion_candidate.v1" +CONTRACT_SCHEMA = "gda.durable_active_metadata_promotion_contract.v1" +OBSERVATION_SCHEMA = "gda.durable_active_metadata_promotion_observation.v1" +EVIDENCE_SCHEMA = "gda.durable_active_metadata_promotion_evidence.v1" +VALIDATION_SCHEMA = "gda.durable_active_metadata_promotion_validation.v1" +ACTION = "metadata_fabric.promote_durable_projection" +TENANT = m319.TENANT +RESOURCE_VERSION_ID = m319.SOURCE_ID +DEFINITION_ID = UUID("a9000000-0000-4000-8000-000000000004") +RUN_ID = UUID("a9000000-0000-4000-8000-000000000005") +WORKLOAD = "workload:durable-active-metadata-promoter" +POLICY_EVALUATOR = "workload:durable-metadata-policy-evaluator" +APPROVER = "human:metadata-platform-owner" +M319_EVIDENCE_SHA256 = ( + "e6d0e3ac4e052029dad0c18d0804626a8af61554a54081c37d8cc9a80c55cd33" +) +JDBC_RESTART_EVIDENCE_FINGERPRINT = ( + "34792bb47ad71041a87adeb644439bf9b6aa3f4855cdc98782d6e3b4282bf1aa" +) + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_PROFILE_PATH = ( + REPO_ROOT / "config/metadata-fabric-durable-active-metadata-promotion.local.yaml" +) +DEFAULT_EVIDENCE_PATH = ( + REPO_ROOT + / "docs/evidence/metadata-fabric-durable-active-metadata-promotion-2026-07-30.json" +) +DEFAULT_WRAPPER_PATH = ( + REPO_ROOT / "scripts/metadata-fabric-durable-active-metadata-promotion.sh" +) +FALSE_CLAIMS = ( + "binding_schema_changed", + "durable_candidate_persisted_to_gda_control", + "dataset_source_committed", + "dataset_absolute_path_committed", + "dataset_required_in_ci", + "deployment_applied", + "protected_workload_identity_verified", + "provider_minimum_privilege_verified", + "durable_catalog_verified", + "production_object_store_verified", + "oidc_verified", + "tls_verified", + "production_scheduler_submission_verified", + "production_ingestion_verified", + "platform_run_succeeded", + "production_ready", +) + + +class DurableActiveMetadataPromotionError(RuntimeError): + """The runtime-bound durable projection promotion failed closed.""" + + +class _FrozenModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class DependencyProfile(_FrozenModel): + m319_evidence_path: str + m319_evidence_sha256: Literal[ + "e6d0e3ac4e052029dad0c18d0804626a8af61554a54081c37d8cc9a80c55cd33" + ] + jdbc_restart_profile_path: str + jdbc_restart_evidence_path: str + jdbc_restart_evidence_fingerprint: Literal[ + "34792bb47ad71041a87adeb644439bf9b6aa3f4855cdc98782d6e3b4282bf1aa" + ] + + +class DurableTarget(_FrozenModel): + metalake: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$") + catalog: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$") + schema_name: str = Field(alias="schema", pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$") + table: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$") + catalog_type: Literal["RELATIONAL"] + catalog_provider: Literal["lakehouse-iceberg"] + catalog_backend: Literal["jdbc"] + uri: Literal[ + "jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg" + ] + warehouse: Literal["file:///var/lib/gravitino/warehouse"] + jdbc_driver: Literal["org.postgresql.Driver"] + + @property + def identity(self) -> str: + return f"{self.metalake}/{self.catalog}/{self.schema_name}/{self.table}" + + +class IdentityProfile(_FrozenModel): + service_admin: Literal["gda-persistence-admin"] + user: Literal["gda-active-metadata-promoter"] + role: Literal["gda-cultural-district-projector"] + material_delivery: Literal["runtime_generated_ephemeral_kubernetes_object"] + + +class AuthorizationProfile(_FrozenModel): + policy_version_ref: str + evaluator_subject: Literal["workload:durable-metadata-policy-evaluator"] + approver_subject: Literal["human:metadata-platform-owner"] + approval_reason: str + + +class ClaimProfile(_FrozenModel): + binding_schema_changed: Literal[False] + durable_candidate_persisted_to_gda_control: Literal[False] + protected_workload_identity_verified: Literal[False] + provider_minimum_privilege_verified: Literal[False] + durable_catalog_verified: Literal[False] + production_object_store_verified: Literal[False] + oidc_verified: Literal[False] + tls_verified: Literal[False] + production_ingestion_verified: Literal[False] + production_ready: Literal[False] + + +class DurablePromotionProfile(_FrozenModel): + profile_schema: Literal["gda.durable_active_metadata_promotion_profile.v1"] = Field( + alias="schema" + ) + environment: Literal["local_docker_desktop"] + dependencies: DependencyProfile + target: DurableTarget + identity: IdentityProfile + authorization: AuthorizationProfile + claims: ClaimProfile + + +class DurableProjectionPlan(_FrozenModel): + plan_schema: Literal["gda.durable_active_metadata_projection_plan.v1"] = Field( + default=PLAN_SCHEMA, alias="schema" + ) + tenant_id: str + run_id: UUID + definition_version_id: UUID + resource_urn: str + resource_version_id: UUID + content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + source_binding_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + openmetadata_ref: bridge.OpenMetadataTableRef + openmetadata_snapshot_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + gravitino_ref: bridge.GravitinoTableRef + target: DurableTarget + runtime_binding: dict[str, Any] + runtime_binding_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + logical_binding_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + promotion_candidate_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + writes_to_gda_control: Literal[False] = False + writes_to_legacy: Literal[False] = False + apply_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + @model_validator(mode="after") + def _fingerprints_match(self) -> DurableProjectionPlan: + if self.target.identity != self.gravitino_ref.identity: + raise ValueError("durable target does not match Gravitino ref") + if self.runtime_binding_sha256 != canonical_json_fingerprint( + self.runtime_binding + ): + raise ValueError("runtime binding fingerprint does not match") + expected_logical = bridge.metadata_fabric_binding_fingerprint( + tenant_id=self.tenant_id, + resource_urn=self.resource_urn, + resource_version_id=self.resource_version_id, + content_sha256=self.content_sha256, + openmetadata=self.openmetadata_ref, + gravitino=(self.gravitino_ref,), + ) + if self.logical_binding_sha256 != expected_logical: + raise ValueError("logical binding fingerprint does not match") + candidate = _promotion_candidate_payload( + tenant_id=self.tenant_id, + resource_urn=self.resource_urn, + resource_version_id=self.resource_version_id, + content_sha256=self.content_sha256, + source_binding_sha256=self.source_binding_sha256, + logical_binding_sha256=self.logical_binding_sha256, + runtime_binding_sha256=self.runtime_binding_sha256, + openmetadata_ref=self.openmetadata_ref, + gravitino_ref=self.gravitino_ref, + ) + if self.promotion_candidate_sha256 != canonical_json_fingerprint(candidate): + raise ValueError("promotion candidate fingerprint does not match") + stable = self.model_dump( + mode="json", by_alias=True, exclude={"apply_plan_sha256"} + ) + if self.apply_plan_sha256 != canonical_json_fingerprint(stable): + raise ValueError("durable apply plan fingerprint does not match") + return self + + +def _mapping(value: Any) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _load_json_object(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise DurableActiveMetadataPromotionError( + f"{path.name} is not valid JSON" + ) from exc + if not isinstance(value, dict): + raise DurableActiveMetadataPromotionError(f"{path.name} must be an object") + return value + + +def _resolve_repo_path(value: str) -> Path: + path = (REPO_ROOT / value).resolve() + try: + path.relative_to(REPO_ROOT) + except ValueError as exc: + raise DurableActiveMetadataPromotionError( + "profile dependency path leaves repository" + ) from exc + return path + + +def load_profile(path: Path = DEFAULT_PROFILE_PATH) -> DurablePromotionProfile: + try: + value = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise TypeError("profile must be an object") + replay._reject_sensitive_fields(value) + profile = DurablePromotionProfile.model_validate(value) + except (OSError, TypeError, ValueError, yaml.YAMLError) as exc: + raise DurableActiveMetadataPromotionError( + "durable promotion profile is invalid" + ) from exc + return profile + + +def _load_dependencies( + profile: DurablePromotionProfile, +) -> tuple[dict[str, Any], jdbc_restart.GravitinoJdbcRestartProfile]: + dependencies = profile.dependencies + m319_evidence = _load_json_object( + _resolve_repo_path(dependencies.m319_evidence_path) + ) + errors = m319.validate_rehearsal_evidence(m319_evidence) + if errors or m319_evidence.get("evidence_sha256") != M319_EVIDENCE_SHA256: + raise DurableActiveMetadataPromotionError( + "M3-19 binding reconciliation evidence does not match" + ) + jdbc_evidence = _load_json_object( + _resolve_repo_path(dependencies.jdbc_restart_evidence_path) + ) + runtime_profile_path = _resolve_repo_path( + dependencies.jdbc_restart_profile_path + ) + jdbc_validation = jdbc_restart.build_validation_report( + profile_path=runtime_profile_path, + evidence_path=_resolve_repo_path(dependencies.jdbc_restart_evidence_path), + ) + if ( + jdbc_validation.get("errors") + or jdbc_restart.verify_evidence_integrity(jdbc_evidence) + or jdbc_evidence.get("evidence_fingerprint") + != JDBC_RESTART_EVIDENCE_FINGERPRINT + ): + raise DurableActiveMetadataPromotionError( + "M3-8 JDBC restart evidence does not match" + ) + runtime_profile = jdbc_restart.load_profile(runtime_profile_path) + return m319_evidence, runtime_profile + + +def _file_record(path: Path) -> dict[str, str | None]: + resolved = path.resolve() + return { + "path": resolved.relative_to(REPO_ROOT).as_posix(), + "sha256": hashlib.sha256(resolved.read_bytes()).hexdigest() + if resolved.is_file() + else None, + } + + +def _promotion_candidate_payload( + *, + tenant_id: str, + resource_urn: str, + resource_version_id: UUID, + content_sha256: str, + source_binding_sha256: str, + logical_binding_sha256: str, + runtime_binding_sha256: str, + openmetadata_ref: bridge.OpenMetadataTableRef, + gravitino_ref: bridge.GravitinoTableRef, +) -> dict[str, Any]: + return { + "schema": PROMOTION_SCHEMA, + "tenant_id": tenant_id, + "resource_urn": resource_urn, + "resource_version_id": str(resource_version_id), + "content_sha256": content_sha256, + "source_binding_sha256": source_binding_sha256, + "logical_binding_sha256": logical_binding_sha256, + "runtime_binding_sha256": runtime_binding_sha256, + "openmetadata_ref": openmetadata_ref.model_dump(mode="json"), + "gravitino_ref": gravitino_ref.model_dump(mode="json"), + "binding_schema_changed": False, + "persisted_to_gda_control": False, + } + + +def _provider_runtime_binding( + snapshot: Mapping[str, Any], + *, + cluster_uid: str, + target: DurableTarget, +) -> dict[str, Any]: + namespace = _mapping(snapshot.get("namespace")) + service = _mapping(snapshot.get("service")) + postgresql = _mapping(snapshot.get("postgresql")) + gravitino = _mapping(snapshot.get("gravitino")) + postgresql_pvc = _mapping(postgresql.get("pvc")) + warehouse_pvc = _mapping(gravitino.get("pvc")) + binding = { + "schema": "gda.provider_runtime_binding.v1", + "context": snapshot.get("context"), + "cluster_uid": cluster_uid, + "namespace": { + "name": namespace.get("name"), + "uid": namespace.get("uid"), + }, + "service": { + "name": service.get("name"), + "uid": service.get("uid"), + }, + "workloads": { + "postgresql_statefulset_uid": postgresql.get("statefulset_uid"), + "gravitino_statefulset_uid": gravitino.get("statefulset_uid"), + }, + "storage": { + "postgresql_pvc_uid": postgresql_pvc.get("uid"), + "postgresql_volume_name": postgresql_pvc.get("volume_name"), + "warehouse_pvc_uid": warehouse_pvc.get("uid"), + "warehouse_volume_name": warehouse_pvc.get("volume_name"), + }, + "images": { + "postgresql_image_id": postgresql.get("image_id"), + "gravitino_image_id": gravitino.get("image_id"), + }, + "catalog": { + "backend": target.catalog_backend, + "uri": target.uri, + "warehouse": target.warehouse, + }, + } + serialized = json.dumps(binding, ensure_ascii=True, sort_keys=True) + if not cluster_uid or any( + token in serialized for token in ('null', '"uid": ""') + ): + raise DurableActiveMetadataPromotionError( + "provider runtime identity is incomplete" + ) + return binding + + +def _verify_openmetadata( + payload: dict[str, Any], + source: Mapping[str, Any], + *, + observed_at: datetime, +) -> bridge.OpenMetadataObservation: + expected = _mapping(_mapping(source.get("first_readback")).get("openmetadata")) + ref = bridge.OpenMetadataTableRef( + entity_id=UUID(str(payload["id"])), + fully_qualified_name=str(expected["fully_qualified_name"]), + entity_version=str(payload["version"]), + server_version="1.13.1", + ) + observation = bridge.parse_openmetadata_table_observation( + ref, payload, observed_at=observed_at + ) + actual = { + "entity_id": str(observation.ref.entity_id), + "fully_qualified_name": observation.ref.fully_qualified_name, + "entity_version": observation.ref.entity_version, + "resource_urn": observation.resource_urn, + "resource_version_id": str(observation.resource_version_id), + "content_sha256": observation.content_sha256, + "owner_refs": sorted(observation.owner_refs), + "domain_refs": sorted(observation.domain_refs), + "tag_refs": sorted(observation.tag_refs), + "snapshot_sha256": observation.snapshot_sha256, + } + normalized_expected = { + **dict(expected), + "owner_refs": sorted(expected.get("owner_refs", [])), + "domain_refs": sorted(expected.get("domain_refs", [])), + "tag_refs": sorted(expected.get("tag_refs", [])), + } + if actual != normalized_expected: + raise DurableActiveMetadataPromotionError( + "retained OpenMetadata projection drifted from M3-19" + ) + return observation + + +def build_projection_plan( + profile: DurablePromotionProfile, + source: Mapping[str, Any], + openmetadata: bridge.OpenMetadataObservation, + runtime_binding: dict[str, Any], +) -> DurableProjectionPlan: + first = _mapping(source.get("first_readback")) + resource_urn = openmetadata.resource_urn + content_sha256 = openmetadata.content_sha256 + provider_revision = f"shapefile-bundle-{content_sha256[:16]}" + gravitino_ref = bridge.GravitinoTableRef( + metalake=profile.target.metalake, + catalog=profile.target.catalog, + schema_name=profile.target.schema_name, + table_name=profile.target.table, + provider_revision=provider_revision, + server_version="1.3.0", + ) + logical_binding_sha256 = bridge.metadata_fabric_binding_fingerprint( + tenant_id=TENANT, + resource_urn=resource_urn, + resource_version_id=openmetadata.resource_version_id, + content_sha256=content_sha256, + openmetadata=openmetadata.ref, + gravitino=(gravitino_ref,), + ) + runtime_binding_sha256 = canonical_json_fingerprint(runtime_binding) + candidate = _promotion_candidate_payload( + tenant_id=TENANT, + resource_urn=resource_urn, + resource_version_id=openmetadata.resource_version_id, + content_sha256=content_sha256, + source_binding_sha256=str(first["binding_candidate_sha256"]), + logical_binding_sha256=logical_binding_sha256, + runtime_binding_sha256=runtime_binding_sha256, + openmetadata_ref=openmetadata.ref, + gravitino_ref=gravitino_ref, + ) + values = { + "tenant_id": TENANT, + "run_id": RUN_ID, + "definition_version_id": DEFINITION_ID, + "resource_urn": resource_urn, + "resource_version_id": openmetadata.resource_version_id, + "content_sha256": content_sha256, + "source_binding_sha256": str(first["binding_candidate_sha256"]), + "openmetadata_ref": openmetadata.ref, + "openmetadata_snapshot_sha256": openmetadata.snapshot_sha256, + "gravitino_ref": gravitino_ref, + "target": profile.target, + "runtime_binding": runtime_binding, + "runtime_binding_sha256": runtime_binding_sha256, + "logical_binding_sha256": logical_binding_sha256, + "promotion_candidate_sha256": canonical_json_fingerprint(candidate), + } + stable = { + "schema": PLAN_SCHEMA, + **{ + key: value.model_dump(mode="json", by_alias=True) + if isinstance(value, BaseModel) + else str(value) + if isinstance(value, UUID) + else value + for key, value in values.items() + }, + "writes_to_gda_control": False, + "writes_to_legacy": False, + } + return DurableProjectionPlan( + **values, + apply_plan_sha256=canonical_json_fingerprint(stable), + ) + + +def _build_execution_plan_artifact( + plan: DurableProjectionPlan, *, created_at: datetime +) -> Artifact: + manifest = { + "schema": "gda.durable_active_metadata_execution_plan.v1", + "plan": plan.model_dump(mode="json", by_alias=True), + } + artifact_id = uuid5(RUN_ID, f"durable-promotion:{plan.apply_plan_sha256}") + content = canonical_json_bytes(manifest) + return Artifact( + tenant_id=TENANT, + artifact_id=artifact_id, + artifact_key=f"durable-active-metadata:{artifact_id}", + artifact_role=ArtifactRole.EXECUTION_PLAN, + storage_uri=f"postgresql://gda-control/execution-plans/{TENANT}/{artifact_id}", + media_type="application/vnd.gda.durable-active-metadata-plan+json", + content_sha256=canonical_json_fingerprint(manifest), + size_bytes=len(content), + run_id=None, + resource_version_id=DEFINITION_ID, + manifest=manifest, + created_by=WORKLOAD, + created_at=created_at, + ) + + +def build_apply_authorization( + plan: DurableProjectionPlan, + profile: DurablePromotionProfile, + *, + authorized_at: datetime, +) -> tuple[PlatformRun, Artifact, Artifact, Artifact, str]: + if authorized_at.tzinfo is None or authorized_at.utcoffset() is None: + raise DurableActiveMetadataPromotionError( + "authorization timestamp must include a timezone" + ) + subject = SubjectContext( + tenant_id=TENANT, + subject_id=WORKLOAD.removeprefix("workload:"), + subject_type="workload", + roles=("metadata_projector",), + purpose="promote a content-bound projection into a durable local catalog", + ) + execution_plan = _build_execution_plan_artifact( + plan, created_at=authorized_at - timedelta(seconds=3) + ) + decision = PolicyDecision( + tenant_id=TENANT, + run_id=RUN_ID, + subject_context=subject, + action=ACTION, + definition_version_id=DEFINITION_ID, + resource_version_ids=(DEFINITION_ID, RESOURCE_VERSION_ID), + execution_plan_artifact_id=execution_plan.artifact_id, + effect="allow", + policy_version_ref=profile.authorization.policy_version_ref, + evaluator_subject=profile.authorization.evaluator_subject, + requires_approval=True, + obligations=(), + decided_at=authorized_at - timedelta(seconds=3), + expires_at=authorized_at + timedelta(days=365), + ) + policy_artifact = build_policy_decision_artifact(decision) + approval = ApprovalRecord( + tenant_id=TENANT, + run_id=RUN_ID, + definition_version_id=DEFINITION_ID, + policy_decision_artifact_id=policy_artifact.artifact_id, + policy_decision_sha256=policy_artifact.content_sha256, + verdict="approved", + approver_subject=profile.authorization.approver_subject, + reason=profile.authorization.approval_reason, + decided_at=authorized_at - timedelta(seconds=2), + expires_at=authorized_at + timedelta(days=180), + ) + approval_artifact = build_approval_artifact(approval) + run = PlatformRun( + tenant_id=TENANT, + run_id=RUN_ID, + definition_version_id=DEFINITION_ID, + orchestration_class="dataops", + subject_context=subject, + input_bindings=( + { + "binding_name": "metadata_change", + "resource_version_id": RESOURCE_VERSION_ID, + "semantic_type": "gis.cultural_districts", + }, + ), + idempotency_key=( + f"durable-active-metadata:{plan.promotion_candidate_sha256}" + ), + policy_refs=RunPolicyReferences( + policy_decision_artifact_id=policy_artifact.artifact_id, + approval_artifact_id=approval_artifact.artifact_id, + ), + submitted_at=authorized_at - timedelta(seconds=1), + ) + validate_run_authorization_evidence( + run, + policy_artifact, + approval_artifact, + execution_plan, + at=authorized_at, + expected_action=ACTION, + ) + stable = { + "run": run.model_dump(mode="json"), + "execution_plan": execution_plan.model_dump(mode="json"), + "policy_decision": policy_artifact.model_dump(mode="json"), + "approval": approval_artifact.model_dump(mode="json"), + } + return ( + run, + execution_plan, + policy_artifact, + approval_artifact, + canonical_json_fingerprint(stable), + ) + + +def validate_apply_authorization( + plan: DurableProjectionPlan, + authorization: tuple[PlatformRun, Artifact, Artifact, Artifact, str], + *, + at: datetime, +) -> None: + run, execution_plan, policy_artifact, approval_artifact, fingerprint = ( + authorization + ) + if _mapping(execution_plan.manifest.get("plan")) != plan.model_dump( + mode="json", by_alias=True + ): + raise DurableActiveMetadataPromotionError( + "execution plan artifact does not contain the exact durable plan" + ) + validate_run_authorization_evidence( + run, + policy_artifact, + approval_artifact, + execution_plan, + at=at, + expected_action=ACTION, + ) + stable = { + "run": run.model_dump(mode="json"), + "execution_plan": execution_plan.model_dump(mode="json"), + "policy_decision": policy_artifact.model_dump(mode="json"), + "approval": approval_artifact.model_dump(mode="json"), + } + if fingerprint != canonical_json_fingerprint(stable): + raise DurableActiveMetadataPromotionError( + "durable apply authorization fingerprint does not match" + ) + + +def _expected_role(profile: DurablePromotionProfile) -> list[dict[str, Any]]: + return [ + { + "fullName": profile.target.catalog, + "type": "CATALOG", + "privileges": [{"name": "USE_CATALOG", "condition": "ALLOW"}], + }, + { + "fullName": ( + f"{profile.target.catalog}.{profile.target.schema_name}" + ), + "type": "SCHEMA", + "privileges": [ + {"name": "CREATE_TABLE", "condition": "ALLOW"}, + {"name": "USE_SCHEMA", "condition": "ALLOW"}, + ], + }, + ] + + +def _table_projection(payload: Mapping[str, Any]) -> dict[str, Any]: + table = _mapping(payload.get("table")) + columns = [] + for value in table.get("columns", []): + column = _mapping(value) + columns.append( + { + "name": column.get("name"), + "type": column.get("type"), + "nullable": column.get("nullable"), + } + ) + properties = _mapping(table.get("properties")) + return { + "name": table.get("name"), + "columns": columns, + "resource_urn": properties.get("gda.resource_urn"), + "resource_version_id": properties.get("gda.resource_version_id"), + "content_sha256": properties.get("gda.content_sha256"), + "provider_revision": properties.get("gda.provider_revision"), + } + + +class DurableProjectionRehearsal: + """Bootstrap a JDBC catalog and apply through one bounded Basic principal.""" + + def __init__( + self, + *, + base_url: str, + admin_name: str, + admin_material: SecretStr, + ) -> None: + self.base_url = base_url + self.admin = identity._BasicApi( + base_url=base_url, + username=admin_name, + material=admin_material, + ) + self.clients: list[identity._BasicApi] = [] + self.bounded: identity._BasicApi | None = None + + def close(self) -> None: + for client in self.clients: + client.close() + self.clients.clear() + self.bounded = None + self.admin.close() + + @staticmethod + def _catalog_path(target: DurableTarget, catalog: str | None = None) -> str: + return ( + f"metalakes/{quote(target.metalake)}/catalogs/" + f"{quote(catalog or target.catalog)}" + ) + + @classmethod + def _schema_path(cls, target: DurableTarget) -> str: + return ( + f"{cls._catalog_path(target)}/schemas/{quote(target.schema_name)}" + ) + + @classmethod + def _table_path(cls, target: DurableTarget) -> str: + return f"{cls._schema_path(target)}/tables/{quote(target.table)}" + + def _bounded_api( + self, profile: DurablePromotionProfile, material: SecretStr + ) -> identity._BasicApi: + client = identity._BasicApi( + base_url=self.base_url, + username=profile.identity.user, + material=material, + ) + self.clients.append(client) + self.bounded = client + return client + + def bootstrap( + self, + profile: DurablePromotionProfile, + *, + database_material: SecretStr, + user_material: SecretStr, + ) -> dict[str, Any]: + admin_status, version_payload = self.admin.request( + "GET", "version", label="durable promotion admin authentication" + ) + version = _mapping(_mapping(version_payload).get("version")).get("version") + if version is None: + version = _mapping(version_payload).get("version") + _, metalake_payload = self.admin.request( + "POST", + "metalakes", + json_body={ + "name": profile.target.metalake, + "comment": "Real Chongqing durable Active Metadata promotion", + "properties": {"gda.environment": "local_durable_promotion"}, + }, + label="durable promotion metalake create", + ) + metalake = identity._response_entity( + metalake_payload, "metalake", "durable promotion metalake" + ) + _, catalog_payload = self.admin.request( + "POST", + f"metalakes/{quote(profile.target.metalake)}/catalogs", + json_body={ + "name": profile.target.catalog, + "type": profile.target.catalog_type, + "provider": profile.target.catalog_provider, + "comment": "JDBC-backed real Active Metadata projection catalog", + "properties": { + "catalog-backend": profile.target.catalog_backend, + "uri": profile.target.uri, + "warehouse": profile.target.warehouse, + "jdbc-user": "gravitino", + "jdbc-password": database_material.get_secret_value(), + "gravitino.bypass.jdbc-driver": profile.target.jdbc_driver, + "gravitino.bypass.jdbc-initialize": "true", + }, + }, + label="durable promotion JDBC catalog create", + ) + catalog = identity._response_entity( + catalog_payload, "catalog", "durable promotion catalog" + ) + _, schema_payload = self.admin.request( + "POST", + f"{self._catalog_path(profile.target)}/schemas", + json_body={ + "name": profile.target.schema_name, + "comment": "Content-bound cultural heritage projection schema", + "properties": {}, + }, + label="durable promotion schema create", + ) + schema = identity._response_entity( + schema_payload, "schema", "durable promotion schema" + ) + self.admin.request( + "POST", + "idp/users", + json_body={ + "user": profile.identity.user, + "password": user_material.get_secret_value(), + }, + label="durable promotion IdP user create", + ) + self.admin.request( + "POST", + f"metalakes/{quote(profile.target.metalake)}/users", + json_body={"name": profile.identity.user}, + label="durable promotion metalake user register", + ) + self.admin.request( + "POST", + f"metalakes/{quote(profile.target.metalake)}/roles", + json_body={ + "name": profile.identity.role, + "properties": {"gda.scope": "cultural_district_table_projection"}, + "securableObjects": _expected_role(profile), + }, + label="durable promotion bounded role create", + ) + self.admin.request( + "PUT", + ( + f"metalakes/{quote(profile.target.metalake)}/permissions/users/" + f"{quote(profile.identity.user)}/grant" + ), + json_body={"roleNames": [profile.identity.role]}, + label="durable promotion bounded role grant", + ) + _, role_payload = self.admin.request( + "GET", + ( + f"metalakes/{quote(profile.target.metalake)}/roles/" + f"{quote(profile.identity.role)}" + ), + label="durable promotion role readback", + ) + role = identity._response_entity( + role_payload, "role", "durable promotion role readback" + ) + bounded = self._bounded_api(profile, user_material) + bounded_status, _ = bounded.request( + "GET", "version", label="durable promotion bounded authentication" + ) + denied_status, _ = bounded.request( + "POST", + f"metalakes/{quote(profile.target.metalake)}/catalogs", + json_body={ + "name": "unauthorized_catalog", + "type": "RELATIONAL", + "provider": "lakehouse-iceberg", + "comment": "must remain denied", + "properties": { + "catalog-backend": "memory", + "uri": "file:///tmp/gda-denied", + "warehouse": "file:///tmp/gda-denied", + }, + }, + expected=frozenset({200, 403}), + label="durable promotion catalog administration denial", + ) + return { + "admin_authentication_status": admin_status, + "bounded_authentication_status": bounded_status, + "server_version": version, + "metalake": metalake.get("name"), + "catalog": catalog.get("name"), + "schema": schema.get("name"), + "catalog_backend": profile.target.catalog_backend, + "catalog_uri": profile.target.uri, + "warehouse": profile.target.warehouse, + "role": { + "name": role.get("name"), + "securable_objects": identity._normalize_securable_objects( + role.get("securableObjects") + ), + }, + "denied_catalog_create_status": denied_status, + "material_recorded": False, + } + + def apply_once( + self, + plan: DurableProjectionPlan, + authorization: tuple[PlatformRun, Artifact, Artifact, Artifact, str], + *, + at: datetime, + create: bool = False, + ) -> dict[str, Any]: + validate_apply_authorization(plan, authorization, at=at) + if self.bounded is None: + raise DurableActiveMetadataPromotionError( + "bounded Gravitino principal is unavailable" + ) + path = self._table_path(plan.target) + mutations: list[str] = [] + if create: + _, payload = self.bounded.request( + "POST", + f"{self._schema_path(plan.target)}/tables", + json_body={ + "name": plan.target.table, + "comment": "Real Chongqing cultural district projection", + "columns": [ + { + "name": "BSM", + "type": "string", + "nullable": False, + "comment": "Cultural district identifier", + }, + { + "name": "geometry", + "type": "binary", + "nullable": False, + "comment": "PolygonZ geometry bytes", + }, + ], + "properties": { + "gda.resource_urn": plan.resource_urn, + "gda.resource_version_id": str(plan.resource_version_id), + "gda.content_sha256": plan.content_sha256, + "gda.provider_revision": plan.gravitino_ref.provider_revision, + }, + }, + label="bounded durable projection table create", + ) + mutations.append("gravitino.table.create") + else: + _, payload = self.bounded.request( + "GET", + path, + label="durable projection table lookup", + ) + assert payload is not None + _, read_payload = self.bounded.request( + "GET", path, label="durable projection exact readback" + ) + assert read_payload is not None + observation = bridge.parse_gravitino_table_observation( + plan.gravitino_ref, read_payload, observed_at=at + ) + if ( + observation.resource_urn != plan.resource_urn + or observation.resource_version_id != plan.resource_version_id + or observation.content_sha256 != plan.content_sha256 + or observation.provider_revision != plan.gravitino_ref.provider_revision + ): + raise DurableActiveMetadataPromotionError( + "durable Gravitino projection does not match the ResourceVersion" + ) + projection = _table_projection(read_payload) + expected_projection = { + "name": plan.target.table, + "columns": [ + {"name": "BSM", "type": "string", "nullable": False}, + {"name": "geometry", "type": "binary", "nullable": False}, + ], + "resource_urn": plan.resource_urn, + "resource_version_id": str(plan.resource_version_id), + "content_sha256": plan.content_sha256, + "provider_revision": plan.gravitino_ref.provider_revision, + } + if projection != expected_projection: + raise DurableActiveMetadataPromotionError( + "durable Gravitino table projection drifted" + ) + return { + "status": "created" if mutations else "no_op", + "mutations": mutations, + "mutation_count": len(mutations), + "gravitino": { + "identity": observation.ref.identity, + "resource_urn": observation.resource_urn, + "resource_version_id": str(observation.resource_version_id), + "content_sha256": observation.content_sha256, + "provider_revision": observation.provider_revision, + "snapshot_sha256": observation.snapshot_sha256, + }, + "table_projection": projection, + "table_projection_sha256": canonical_json_fingerprint(projection), + "logical_binding_sha256": plan.logical_binding_sha256, + "promotion_candidate_sha256": plan.promotion_candidate_sha256, + } + + def reconnect_bounded( + self, profile: DurablePromotionProfile, user_material: SecretStr + ) -> dict[str, Any]: + bounded = self._bounded_api(profile, user_material) + status, _ = bounded.request( + "GET", "version", label="post-restart bounded authentication" + ) + _, role_payload = self.admin.request( + "GET", + ( + f"metalakes/{quote(profile.target.metalake)}/roles/" + f"{quote(profile.identity.role)}" + ), + label="post-restart durable promotion role readback", + ) + role = identity._response_entity( + role_payload, "role", "post-restart durable promotion role" + ) + denied_status, _ = bounded.request( + "POST", + f"metalakes/{quote(profile.target.metalake)}/catalogs", + json_body={ + "name": "unauthorized_catalog", + "type": "RELATIONAL", + "provider": "lakehouse-iceberg", + "comment": "must remain denied after restart", + "properties": { + "catalog-backend": "memory", + "uri": "file:///tmp/gda-denied", + "warehouse": "file:///tmp/gda-denied", + }, + }, + expected=frozenset({200, 403}), + label="post-restart catalog administration denial", + ) + return { + "bounded_authentication_status": status, + "role": { + "name": role.get("name"), + "securable_objects": identity._normalize_securable_objects( + role.get("securableObjects") + ), + }, + "denied_catalog_create_status": denied_status, + "material_recorded": False, + } + + +def _runtime_continuity_errors( + restart: Mapping[str, Any], + runtime_binding: Mapping[str, Any], + *, + cluster_uid: str, + target: DurableTarget, +) -> list[str]: + errors = jdbc_restart._restart_errors(restart) + before = _mapping(restart.get("before")) + after = _mapping(restart.get("after")) + try: + before_binding = _provider_runtime_binding( + before, cluster_uid=cluster_uid, target=target + ) + after_binding = _provider_runtime_binding( + after, cluster_uid=cluster_uid, target=target + ) + except DurableActiveMetadataPromotionError: + errors.append("durable provider runtime identity is incomplete") + return errors + if before_binding != after_binding or dict(runtime_binding) != before_binding: + errors.append("durable provider runtime identity changed across restart") + return errors + + +def build_contract_report( + *, + profile_path: Path = DEFAULT_PROFILE_PATH, + wrapper_path: Path = DEFAULT_WRAPPER_PATH, +) -> dict[str, Any]: + errors: list[str] = [] + source_sha: str | None = None + jdbc_fingerprint: str | None = None + profile: DurablePromotionProfile | None = None + try: + profile = load_profile(profile_path) + source, _runtime_profile = _load_dependencies(profile) + source_sha = str(source.get("evidence_sha256")) + jdbc_evidence = _load_json_object( + _resolve_repo_path(profile.dependencies.jdbc_restart_evidence_path) + ) + jdbc_fingerprint = str(jdbc_evidence.get("evidence_fingerprint")) + except DurableActiveMetadataPromotionError as exc: + errors.append(f"M3-20 dependency contract is invalid: {type(exc).__name__}") + try: + wrapper = wrapper_path.read_text(encoding="utf-8") + for marker in ( + "set -euo pipefail", + "metadata_fabric_durable_active_metadata_promotion", + '"$@"', + ): + if marker not in wrapper: + errors.append(f"M3-20 wrapper is missing marker: {marker}") + except OSError as exc: + errors.append(f"M3-20 wrapper is invalid: {type(exc).__name__}") + files = { + "implementation": _file_record(Path(__file__)), + "profile": _file_record(profile_path), + "wrapper": _file_record(wrapper_path), + } + stable = { + "schema": CONTRACT_SCHEMA, + "source_m319_evidence_sha256": source_sha, + "jdbc_restart_evidence_fingerprint": jdbc_fingerprint, + "target_identity": profile.target.identity if profile else None, + "runtime_binding_scope": ( + "cluster_namespace_service_statefulsets_persistent_volumes" + ), + "promotion_mode": ( + "bounded_create_then_pre_and_post_restart_zero_mutation_replay" + ), + "binding_ledger_mode": "promotion_candidate_only_no_ledger_write", + "files": files, + "errors": errors, + } + return { + **stable, + "status": "valid" if not errors else "invalid", + "contract_sha256": canonical_json_fingerprint(stable), + "provider_mutations_executed": False, + "durable_candidate_persisted_to_gda_control": False, + "durable_catalog_verified": False, + "production_ready": False, + } + + +def build_evidence( + observation: Mapping[str, Any], + *, + profile: DurablePromotionProfile, +) -> dict[str, Any]: + errors: list[str] = [] + try: + replay._reject_sensitive_fields(observation) + except ValueError: + errors.append("durable promotion observation contains sensitive material") + if observation.get("schema") != OBSERVATION_SCHEMA: + errors.append("durable promotion observation schema does not match") + contract = build_contract_report() + observed_contract = _mapping(observation.get("contract")) + if ( + observed_contract.get("contract_sha256") + != contract.get("contract_sha256") + or observed_contract.get("source_m319_evidence_sha256") + != M319_EVIDENCE_SHA256 + or observed_contract.get("jdbc_restart_evidence_fingerprint") + != JDBC_RESTART_EVIDENCE_FINGERPRINT + ): + errors.append("durable promotion contract binding does not match") + dataset = _mapping(observation.get("dataset_bundle")) + errors.extend(validate_shapefile_bundle_inventory(dict(dataset))) + plan = _mapping(observation.get("plan")) + if ( + plan.get("resource_version_id") != str(RESOURCE_VERSION_ID) + or plan.get("content_sha256") != dataset.get("content_sha256") + or plan.get("target", {}).get("metalake") != profile.target.metalake + or plan.get("target", {}).get("catalog") != profile.target.catalog + or plan.get("target", {}).get("schema") != profile.target.schema_name + or plan.get("target", {}).get("table") != profile.target.table + ): + errors.append("durable projection plan does not bind the real ResourceVersion") + runtime_binding = _mapping(observation.get("runtime_binding")) + if observation.get("runtime_binding_sha256") != canonical_json_fingerprint( + runtime_binding + ): + errors.append("runtime-bound promotion fingerprint does not match") + errors.extend( + _runtime_continuity_errors( + _mapping(observation.get("restart")), + runtime_binding, + cluster_uid=str(observation.get("cluster_uid") or ""), + target=profile.target, + ) + ) + openmetadata = _mapping(observation.get("openmetadata")) + source_openmetadata = _mapping( + _mapping(observation.get("source_m319_first_readback")).get("openmetadata") + ) + if dict(openmetadata) != dict(source_openmetadata): + errors.append("retained OpenMetadata readback does not match M3-19") + if observation.get("openmetadata_mutation_count") != 0: + errors.append("durable promotion may not mutate retained OpenMetadata") + bootstrap = _mapping(observation.get("bootstrap")) + post_security = _mapping(observation.get("post_restart_security")) + expected_role = identity._normalize_securable_objects(_expected_role(profile)) + if ( + bootstrap.get("admin_authentication_status") != 200 + or bootstrap.get("bounded_authentication_status") != 200 + or bootstrap.get("server_version") != "1.3.0" + or bootstrap.get("catalog_backend") != "jdbc" + or bootstrap.get("denied_catalog_create_status") != 403 + or _mapping(bootstrap.get("role")).get("securable_objects") + != expected_role + or bootstrap.get("material_recorded") is not False + or post_security.get("bounded_authentication_status") != 200 + or post_security.get("denied_catalog_create_status") != 403 + or _mapping(post_security.get("role")).get("securable_objects") + != expected_role + or post_security.get("material_recorded") is not False + ): + errors.append("bounded Gravitino identity did not survive restart") + first = _mapping(observation.get("first_apply")) + immediate = _mapping(observation.get("immediate_replay")) + post = _mapping(observation.get("post_restart_first_replay")) + if ( + first.get("status") != "created" + or first.get("mutation_count") != 1 + or first.get("mutations") != ["gravitino.table.create"] + ): + errors.append("first durable projection apply was not one bounded create") + for label, result in (("immediate", immediate), ("post-restart", post)): + if result.get("status") != "no_op" or result.get("mutation_count") != 0: + errors.append(f"{label} durable projection replay was not no-op") + for key in ( + "gravitino", + "table_projection", + "table_projection_sha256", + "logical_binding_sha256", + "promotion_candidate_sha256", + ): + if first.get(key) != immediate.get(key) or first.get(key) != post.get(key): + errors.append(f"durable provider projection drifted across replay: {key}") + if ( + first.get("logical_binding_sha256") != plan.get("logical_binding_sha256") + or first.get("promotion_candidate_sha256") + != plan.get("promotion_candidate_sha256") + or plan.get("source_binding_sha256") + == plan.get("logical_binding_sha256") + ): + errors.append("runtime-bound durable promotion candidate does not match plan") + authorization = _mapping(observation.get("authorization")) + if ( + authorization.get("action") != ACTION + or authorization.get("provider_apply_authorized") is not True + or not authorization.get("authorization_sha256") + ): + errors.append("durable provider apply authorization is not bound") + checks = _mapping(observation.get("runtime_checks")) + if ( + checks.get("openmetadata_port_forward_stopped") is not True + or checks.get("gravitino_port_forwards_stopped") is not True + or checks.get("namespace_delete_completed") is not True + or checks.get("namespace_absent") is not True + or checks.get("provider_objects_retained") is not False + or checks.get("persistent_volumes_retained") is not False + or checks.get("material_recorded") is not False + ): + errors.append("durable promotion runtime cleanup is incomplete") + verified = not errors + stable = { + "schema": EVIDENCE_SCHEMA, + "environment": "local_docker_desktop", + "status": ( + "local_durable_active_metadata_promotion_verified" + if verified + else "blocked" + ), + "contract_sha256": contract.get("contract_sha256"), + "source_m319_evidence_sha256": M319_EVIDENCE_SHA256, + "jdbc_restart_evidence_fingerprint": JDBC_RESTART_EVIDENCE_FINGERPRINT, + "dataset_bundle": dict(dataset), + "resource_version_id": plan.get("resource_version_id"), + "resource_version_content_sha256": plan.get("content_sha256"), + "source_binding_sha256": plan.get("source_binding_sha256"), + "logical_binding_sha256": plan.get("logical_binding_sha256"), + "runtime_binding_sha256": observation.get("runtime_binding_sha256"), + "promotion_candidate_sha256": plan.get("promotion_candidate_sha256"), + "local_durable_active_metadata_promotion_verified": verified, + "real_dataset_resource_version_bound": verified, + "openmetadata_read_only_verified": verified, + "bounded_gravitino_projection_verified": verified, + "local_jdbc_catalog_restart_continuity_verified": verified, + "local_provider_runtime_identity_bound": verified, + "pre_restart_replay_no_op_verified": verified, + "post_restart_first_replay_no_op_verified": verified, + "m319_binding_ledger_untouched": verified, + "binding_schema_changed": False, + "durable_candidate_persisted_to_gda_control": False, + "dataset_source_committed": False, + "dataset_absolute_path_committed": False, + "dataset_required_in_ci": False, + "deployment_applied": False, + "protected_workload_identity_verified": False, + "provider_minimum_privilege_verified": False, + "durable_catalog_verified": False, + "production_object_store_verified": False, + "oidc_verified": False, + "tls_verified": False, + "production_scheduler_submission_verified": False, + "production_ingestion_verified": False, + "platform_run_succeeded": False, + "production_ready": False, + "observation": dict(observation), + "errors": errors, + } + return {**stable, "evidence_sha256": canonical_json_fingerprint(stable)} + + +def verify_evidence_integrity(evidence: Mapping[str, Any]) -> list[str]: + errors: list[str] = [] + try: + replay._reject_sensitive_fields(evidence) + except ValueError: + errors.append("durable promotion evidence contains sensitive material") + stable = {key: value for key, value in evidence.items() if key != "evidence_sha256"} + if evidence.get("evidence_sha256") != canonical_json_fingerprint(stable): + errors.append("durable promotion evidence SHA-256 does not match") + if evidence.get("schema") != EVIDENCE_SCHEMA or evidence.get("errors") != []: + errors.append("durable promotion evidence is not verified") + for claim in ( + "local_durable_active_metadata_promotion_verified", + "real_dataset_resource_version_bound", + "openmetadata_read_only_verified", + "bounded_gravitino_projection_verified", + "local_jdbc_catalog_restart_continuity_verified", + "local_provider_runtime_identity_bound", + "pre_restart_replay_no_op_verified", + "post_restart_first_replay_no_op_verified", + "m319_binding_ledger_untouched", + ): + if evidence.get(claim) is not True: + errors.append(f"durable promotion evidence claim is false: {claim}") + for claim in FALSE_CLAIMS: + if evidence.get(claim) is not False: + errors.append(f"durable promotion evidence may not claim {claim}") + serialized = json.dumps(evidence, ensure_ascii=True, sort_keys=True) + for forbidden in ( + "/Users/", + "Downloads/", + ".tmp/", + "host.docker.internal", + '"password"', + '"secret"', + '"token"', + '"session"', + ): + if forbidden in serialized: + errors.append("durable promotion evidence contains local or secret material") + break + return errors + + +def run_live_rehearsal( + profile_path: Path = DEFAULT_PROFILE_PATH, +) -> dict[str, Any]: + profile = load_profile(profile_path) + source, runtime_profile = _load_dependencies(profile) + contract = build_contract_report(profile_path=profile_path) + if contract.get("status") != "valid": + raise DurableActiveMetadataPromotionError( + "durable promotion static contract is invalid" + ) + provider_profile = execution.build_projection_profile(datetime.now(UTC)) + try: + openmetadata_username = os.environ[ + provider_profile.providers.openmetadata.username_env + ] + openmetadata_password = SecretStr( + os.environ[provider_profile.providers.openmetadata.password_env] + ) + except KeyError as exc: + raise DurableActiveMetadataPromotionError( + "OpenMetadata local bootstrap credential environment is missing" + ) from exc + admin_material = SecretStr(secrets.token_urlsafe(24)) + database_material = SecretStr(secrets.token_urlsafe(24)) + user_material = SecretStr(secrets.token_urlsafe(24)) + runtime = jdbc_restart.IsolatedJdbcRestartRuntime(runtime_profile) + om_forward: provider_metrics._PortForward | None = None + before_forward: provider_metrics._PortForward | None = None + after_forward: provider_metrics._PortForward | None = None + om_client: replay.OpenMetadataApplyClient | None = None + rehearsal: DurableProjectionRehearsal | None = None + initial_runtime: dict[str, Any] | None = None + restart: dict[str, Any] | None = None + openmetadata_observation: bridge.OpenMetadataObservation | None = None + plan: DurableProjectionPlan | None = None + authorization: tuple[PlatformRun, Artifact, Artifact, Artifact, str] | None = None + bootstrap: dict[str, Any] | None = None + first_apply: dict[str, Any] | None = None + immediate_replay: dict[str, Any] | None = None + post_restart_security: dict[str, Any] | None = None + post_restart_replay: dict[str, Any] | None = None + cluster_uid: str | None = None + runtime_binding: dict[str, Any] | None = None + om_forward_stopped = False + before_forward_stopped = False + after_forward_stopped = False + cleanup = { + "namespace_delete_completed": False, + "namespace_absent": False, + "provider_objects_retained": True, + "persistent_volumes_retained": True, + } + try: + initial_runtime = runtime.start( + admin_material=admin_material, + database_material=database_material, + ) + cluster = runtime.kubectl.get_json( + ["get", "namespace", "kube-system"], + label="durable promotion cluster identity", + ) + cluster_uid = str(_mapping(_mapping(cluster).get("metadata")).get("uid")) + runtime_binding = _provider_runtime_binding( + initial_runtime, + cluster_uid=cluster_uid, + target=profile.target, + ) + + om_forward = provider_metrics._PortForward( + kubectl="kubectl", + context=provider_profile.cluster.context, + namespace=provider_profile.cluster.namespace, + service=provider_profile.providers.openmetadata.service, + target_port=provider_profile.providers.openmetadata.service_port, + ) + om_forward.start() + om_client = replay.OpenMetadataApplyClient( + base_url=f"http://127.0.0.1:{om_forward.local_port}/api/v1", + username=openmetadata_username, + password=openmetadata_password, + ) + om_payload = om_client.get_table( + provider_profile.targets.openmetadata.table_fqn + ) + if om_payload is None: + raise DurableActiveMetadataPromotionError( + "retained OpenMetadata projection is missing" + ) + openmetadata_observation = _verify_openmetadata( + om_payload, source, observed_at=datetime.now(UTC) + ) + openmetadata_mutation_count = len(om_client.mutations) + om_client.close() + om_client = None + om_forward_stopped = om_forward.stop() + om_forward = None + + plan = build_projection_plan( + profile, source, openmetadata_observation, runtime_binding + ) + authorized_at = datetime.now(UTC) + authorization = build_apply_authorization( + plan, profile, authorized_at=authorized_at + ) + before_forward = provider_metrics._PortForward( + kubectl="kubectl", + context=runtime_profile.cluster.context, + namespace=runtime_profile.cluster.rehearsal_namespace, + service=runtime_profile.runtime.service, + target_port=runtime_profile.runtime.service_port, + ) + before_forward.start() + rehearsal = DurableProjectionRehearsal( + base_url=f"http://127.0.0.1:{before_forward.local_port}/api", + admin_name=profile.identity.service_admin, + admin_material=admin_material, + ) + bootstrap = rehearsal.bootstrap( + profile, + database_material=database_material, + user_material=user_material, + ) + first_apply = rehearsal.apply_once( + plan, authorization, at=authorized_at, create=True + ) + immediate_replay = rehearsal.apply_once( + plan, authorization, at=authorized_at + timedelta(seconds=1) + ) + rehearsal.close() + rehearsal = None + before_forward_stopped = before_forward.stop() + before_forward = None + + restart = runtime.restart() + + after_forward = provider_metrics._PortForward( + kubectl="kubectl", + context=runtime_profile.cluster.context, + namespace=runtime_profile.cluster.rehearsal_namespace, + service=runtime_profile.runtime.service, + target_port=runtime_profile.runtime.service_port, + ) + after_forward.start() + rehearsal = DurableProjectionRehearsal( + base_url=f"http://127.0.0.1:{after_forward.local_port}/api", + admin_name=profile.identity.service_admin, + admin_material=admin_material, + ) + post_restart_security = rehearsal.reconnect_bounded( + profile, user_material + ) + post_restart_replay = rehearsal.apply_once( + plan, authorization, at=authorized_at + timedelta(seconds=2) + ) + finally: + if om_client is not None: + om_client.close() + if rehearsal is not None: + rehearsal.close() + if om_forward is not None: + om_forward_stopped = om_forward.stop() + if before_forward is not None: + before_forward_stopped = before_forward.stop() + if after_forward is not None: + after_forward_stopped = after_forward.stop() + cleanup = runtime.cleanup() + required = ( + initial_runtime, + restart, + openmetadata_observation, + plan, + authorization, + bootstrap, + first_apply, + immediate_replay, + post_restart_security, + post_restart_replay, + cluster_uid, + runtime_binding, + ) + if any(value is None for value in required): + raise DurableActiveMetadataPromotionError( + "durable promotion rehearsal did not produce a complete outcome" + ) + assert isinstance(openmetadata_observation, bridge.OpenMetadataObservation) + assert isinstance(plan, DurableProjectionPlan) + run, execution_plan, policy_artifact, approval_artifact, auth_sha = authorization + source_openmetadata = _mapping( + _mapping(source.get("first_readback")).get("openmetadata") + ) + observation = { + "schema": OBSERVATION_SCHEMA, + "observed_at": datetime.now(UTC).isoformat(), + "contract": { + "contract_sha256": contract["contract_sha256"], + "source_m319_evidence_sha256": M319_EVIDENCE_SHA256, + "jdbc_restart_evidence_fingerprint": ( + JDBC_RESTART_EVIDENCE_FINGERPRINT + ), + }, + "dataset_bundle": source["dataset_bundle"], + "source_m319_first_readback": source["first_readback"], + "openmetadata": { + **dict(source_openmetadata), + "owner_refs": sorted(openmetadata_observation.owner_refs), + "domain_refs": sorted(openmetadata_observation.domain_refs), + "tag_refs": sorted(openmetadata_observation.tag_refs), + }, + "openmetadata_mutation_count": openmetadata_mutation_count, + "cluster_uid": cluster_uid, + "runtime_binding": runtime_binding, + "runtime_binding_sha256": canonical_json_fingerprint(runtime_binding), + "initial_runtime": initial_runtime, + "restart": restart, + "plan": plan.model_dump(mode="json", by_alias=True), + "authorization": { + "action": ACTION, + "provider_apply_authorized": True, + "authorization_sha256": auth_sha, + "run_id": str(run.run_id), + "execution_plan_artifact_id": str(execution_plan.artifact_id), + "policy_decision_artifact_id": str(policy_artifact.artifact_id), + "approval_artifact_id": str(approval_artifact.artifact_id), + }, + "bootstrap": bootstrap, + "first_apply": first_apply, + "immediate_replay": immediate_replay, + "post_restart_security": post_restart_security, + "post_restart_first_replay": post_restart_replay, + "runtime_checks": { + **cleanup, + "openmetadata_port_forward_stopped": om_forward_stopped, + "gravitino_port_forwards_stopped": ( + before_forward_stopped and after_forward_stopped + ), + "material_recorded": False, + }, + } + return build_evidence(observation, profile=profile) + + +def build_validation_report( + *, + profile_path: Path = DEFAULT_PROFILE_PATH, + evidence_path: Path = DEFAULT_EVIDENCE_PATH, +) -> dict[str, Any]: + contract = build_contract_report(profile_path=profile_path) + errors = list(contract["errors"]) + evidence: dict[str, Any] | None = None + try: + evidence = _load_json_object(evidence_path) + errors.extend(verify_evidence_integrity(evidence)) + if evidence.get("contract_sha256") != contract.get("contract_sha256"): + errors.append("durable promotion evidence contract fingerprint is stale") + except DurableActiveMetadataPromotionError as exc: + errors.append(f"durable promotion evidence is invalid: {type(exc).__name__}") + verified = not errors + return { + "schema": VALIDATION_SCHEMA, + "status": "valid" if verified else "invalid", + "local_static_contract_verified": contract.get("status") == "valid", + "local_durable_active_metadata_promotion_verified": ( + verified + and evidence is not None + and evidence.get("local_durable_active_metadata_promotion_verified") + is True + ), + "local_provider_runtime_identity_bound": ( + verified + and evidence is not None + and evidence.get("local_provider_runtime_identity_bound") is True + ), + "durable_candidate_persisted_to_gda_control": False, + "durable_catalog_verified": False, + "production_ingestion_verified": False, + "production_ready": False, + "contract_sha256": contract.get("contract_sha256"), + "evidence_sha256": evidence.get("evidence_sha256") if evidence else None, + "errors": errors, + } + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + validate = subparsers.add_parser("validate") + validate.add_argument("--profile", type=Path, default=DEFAULT_PROFILE_PATH) + validate.add_argument("--evidence", type=Path, default=DEFAULT_EVIDENCE_PATH) + rehearse = subparsers.add_parser("rehearse") + rehearse.add_argument("--profile", type=Path, default=DEFAULT_PROFILE_PATH) + rehearse.add_argument("--evidence-out", type=Path, required=True) + args = parser.parse_args(argv) + + if args.command == "validate": + report = build_validation_report( + profile_path=args.profile, evidence_path=args.evidence + ) + print(json.dumps(report, ensure_ascii=True, indent=2, sort_keys=True)) + return 0 if not report["errors"] else 1 + + evidence = run_live_rehearsal(args.profile) + _write_json(args.evidence_out, evidence) + print(json.dumps(evidence, ensure_ascii=True, indent=2, sort_keys=True)) + return 0 if not evidence["errors"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/data_agent/platform_truth.py b/data_agent/platform_truth.py index 0fcb55a6..127cd6eb 100644 --- a/data_agent/platform_truth.py +++ b/data_agent/platform_truth.py @@ -958,6 +958,26 @@ def _config( ), "Protected identity, durable catalog/executor and production binding ledger", ), + RuntimeSpec( + "metadata_durable_active_metadata_promotion_rehearsal", + "durable_active_metadata_promotion_rehearsal", + "governed", + "evidence_durable", + "temporary JDBC/PVC provider runtime + committed local evidence", + "metadata-platform", + "local_verification_only", + ( + "data_agent/metadata_fabric_durable_active_metadata_promotion.py", + "scripts/metadata-fabric-durable-active-metadata-promotion.sh", + ), + ( + ( + "data_agent/metadata_fabric_durable_active_metadata_promotion.py", + "def run_live_rehearsal", + ), + ), + "Object-store-backed durable provider, protected identity and production binding promotion", + ), RuntimeSpec( "datalake_monitor", "monitor_loop", diff --git a/data_agent/test_metadata_fabric_durable_active_metadata_promotion.py b/data_agent/test_metadata_fabric_durable_active_metadata_promotion.py new file mode 100644 index 00000000..99f2810a --- /dev/null +++ b/data_agent/test_metadata_fabric_durable_active_metadata_promotion.py @@ -0,0 +1,295 @@ +import inspect +import json +from copy import deepcopy +from datetime import UTC, datetime + +import pytest +import yaml + +from data_agent import metadata_fabric_durable_active_metadata_promotion as durable +from data_agent import metadata_fabric_gravitino_jdbc_restart as jdbc_restart +from data_agent.platform_contracts import canonical_json_fingerprint + +AT = datetime(2026, 7, 30, 14, 0, tzinfo=UTC) +EXPECTED_EVIDENCE_SHA256 = ( + "53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b" +) + + +def _profile(): + return durable.load_profile() + + +def _source(): + profile = _profile() + return durable._load_json_object( + durable._resolve_repo_path(profile.dependencies.m319_evidence_path) + ) + + +def _openmetadata(): + source = _source() + observed = source["first_readback"]["openmetadata"] + ref = durable.bridge.OpenMetadataTableRef( + entity_id=observed["entity_id"], + fully_qualified_name=observed["fully_qualified_name"], + entity_version=observed["entity_version"], + server_version="1.13.1", + ) + return durable.bridge.OpenMetadataObservation( + ref=ref, + resource_urn=observed["resource_urn"], + resource_version_id=observed["resource_version_id"], + content_sha256=observed["content_sha256"], + owner_refs=observed["owner_refs"], + domain_refs=observed["domain_refs"], + tag_refs=observed["tag_refs"], + snapshot_sha256=observed["snapshot_sha256"], + observed_at=AT, + ) + + +def _runtime_snapshot(): + return { + "context": "docker-desktop", + "namespace": { + "name": "gda-metadata-catalog-persistence", + "uid": "11111111-1111-4111-8111-111111111111", + }, + "service": { + "name": "gravitino-persistence", + "uid": "22222222-2222-4222-8222-222222222222", + "type": "ClusterIP", + }, + "postgresql": { + "statefulset_uid": "33333333-3333-4333-8333-333333333333", + "pod_uid": "44444444-4444-4444-8444-444444444444", + "pod_name": "gravitino-persistence-postgresql-0", + "node_name": "desktop-worker", + "ready_replicas": 1, + "service_account": "gravitino-persistence-postgresql", + "service_account_automount_disabled": True, + "image": "docker.io/library/postgres:16.10-bookworm", + "image_id": f"docker-pullable://postgres@{jdbc_restart.POSTGRESQL_IMAGE_DIGEST}", + "pvc": { + "name": "data-gravitino-persistence-postgresql-0", + "uid": "55555555-5555-4555-8555-555555555555", + "storage_class": "standard", + "volume_name": "pvc-postgresql", + "phase": "Bound", + }, + }, + "gravitino": { + "statefulset_uid": "66666666-6666-4666-8666-666666666666", + "pod_uid": "77777777-7777-4777-8777-777777777777", + "pod_name": "gravitino-persistence-0", + "node_name": "desktop-worker", + "ready_replicas": 1, + "service_account": "gravitino-persistence", + "service_account_automount_disabled": True, + "image": "docker.io/gda/gravitino:1.3.0-local-arm64", + "image_id": f"docker-pullable://gda/gravitino@{jdbc_restart.GRAVITINO_KUBERNETES_IMAGE_ID}", + "pvc": { + "name": "warehouse-gravitino-persistence-0", + "uid": "88888888-8888-4888-8888-888888888888", + "storage_class": "standard", + "volume_name": "pvc-warehouse", + "phase": "Bound", + }, + }, + "gravitino_host_image_id": jdbc_restart.GRAVITINO_HOST_IMAGE_ID, + "jdbc_driver_mounted": True, + "source_schema_sha256": jdbc_restart.GRAVITINO_SCHEMA_SHA256, + } + + +def _runtime_binding(snapshot=None): + return durable._provider_runtime_binding( + snapshot or _runtime_snapshot(), + cluster_uid="99999999-9999-4999-8999-999999999999", + target=_profile().target, + ) + + +def _plan(runtime_binding=None): + return durable.build_projection_plan( + _profile(), + _source(), + _openmetadata(), + runtime_binding or _runtime_binding(), + ) + + +def test_profile_binds_checked_m319_and_jdbc_restart_dependencies(): + profile = _profile() + source, runtime_profile = durable._load_dependencies(profile) + + assert source["evidence_sha256"] == durable.M319_EVIDENCE_SHA256 + assert source["dataset_bundle"]["content_sha256"] == ( + "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007" + ) + assert runtime_profile.catalog.backend == "jdbc" + assert runtime_profile.catalog.warehouse == "file:///var/lib/gravitino/warehouse" + assert profile.target.identity == ( + "gda_chongqing_m3_20/lakehouse/cultural_heritage/cultural_districts" + ) + + +def test_dependencies_use_full_jdbc_restart_validator(monkeypatch): + profile = _profile() + monkeypatch.setattr( + jdbc_restart, + "build_validation_report", + lambda **_kwargs: {"errors": ["stale runtime contract"]}, + ) + + with pytest.raises( + durable.DurableActiveMetadataPromotionError, + match="M3-8 JDBC restart evidence does not match", + ): + durable._load_dependencies(profile) + + +def test_runtime_bound_plan_is_distinct_from_m319_memory_binding(): + plan = _plan() + source_binding = _source()["binding_sha256"] + + assert plan.resource_version_id == durable.RESOURCE_VERSION_ID + assert plan.content_sha256 == _source()["dataset_bundle"]["content_sha256"] + assert plan.source_binding_sha256 == source_binding + assert plan.logical_binding_sha256 != source_binding + assert plan.gravitino_ref.identity == _profile().target.identity + assert plan.runtime_binding_sha256 == canonical_json_fingerprint( + plan.runtime_binding + ) + assert plan.writes_to_gda_control is False + assert plan.writes_to_legacy is False + + +def test_provider_runtime_change_changes_only_runtime_bound_candidate(): + first = _plan() + changed = deepcopy(first.runtime_binding) + changed["service"]["uid"] = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + second = _plan(changed) + + assert second.logical_binding_sha256 == first.logical_binding_sha256 + assert second.runtime_binding_sha256 != first.runtime_binding_sha256 + assert ( + second.promotion_candidate_sha256 != first.promotion_candidate_sha256 + ) + + +def test_apply_authorization_binds_exact_runtime_plan(): + plan = _plan() + authorization = durable.build_apply_authorization( + plan, _profile(), authorized_at=AT + ) + + durable.validate_apply_authorization(plan, authorization, at=AT) + assert authorization[-1] + + changed = deepcopy(plan.runtime_binding) + changed["namespace"]["uid"] = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + drifted = _plan(changed) + with pytest.raises( + durable.DurableActiveMetadataPromotionError, + match="does not contain the exact durable plan", + ): + durable.validate_apply_authorization(drifted, authorization, at=AT) + + +def test_first_apply_is_explicit_create_and_replay_is_read_only(): + signature = inspect.signature(durable.DurableProjectionRehearsal.apply_once) + + assert signature.parameters["create"].default is False + + +def test_runtime_binding_excludes_pod_identity_but_restart_requires_rotation(): + before = _runtime_snapshot() + after = deepcopy(before) + after["postgresql"]["pod_uid"] = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + after["gravitino"]["pod_uid"] = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" + binding = _runtime_binding(before) + + assert durable._runtime_continuity_errors( + {"before": before, "after": after}, + binding, + cluster_uid="99999999-9999-4999-8999-999999999999", + target=_profile().target, + ) == [] + + after["gravitino"]["pvc"]["uid"] = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" + errors = durable._runtime_continuity_errors( + {"before": before, "after": after}, + binding, + cluster_uid="99999999-9999-4999-8999-999999999999", + target=_profile().target, + ) + assert "gravitino PVC identity changed" in errors + assert "durable provider runtime identity changed across restart" in errors + + +def test_profile_rejects_memory_catalog_and_sensitive_fields(tmp_path): + profile = json.loads(json.dumps(yaml_safe_load(durable.DEFAULT_PROFILE_PATH))) + profile["target"]["catalog_backend"] = "memory" + profile["identity"]["user_password"] = "must-not-enter-profile" + path = tmp_path / "profile.yaml" + path.write_text(json.dumps(profile), encoding="utf-8") + + with pytest.raises( + durable.DurableActiveMetadataPromotionError, + match="profile is invalid", + ): + durable.load_profile(path) + + +def yaml_safe_load(path): + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def test_contract_is_valid_and_wrapper_is_fail_closed(): + contract = durable.build_contract_report() + wrapper = durable.DEFAULT_WRAPPER_PATH.read_text(encoding="utf-8") + + assert contract["status"] == "valid" + assert contract["source_m319_evidence_sha256"] == durable.M319_EVIDENCE_SHA256 + assert contract["binding_ledger_mode"] == ( + "promotion_candidate_only_no_ledger_write" + ) + assert "set -euo pipefail" in wrapper + assert "metadata_fabric_durable_active_metadata_promotion" in wrapper + + +def test_checked_evidence_is_runtime_bound_and_fail_closed(): + if not durable.DEFAULT_EVIDENCE_PATH.is_file(): + pytest.skip("checked M3-20 evidence is generated by the live rehearsal") + evidence = durable._load_json_object(durable.DEFAULT_EVIDENCE_PATH) + + assert durable.verify_evidence_integrity(evidence) == [] + assert evidence["evidence_sha256"] == EXPECTED_EVIDENCE_SHA256 + assert evidence["local_durable_active_metadata_promotion_verified"] is True + assert evidence["post_restart_first_replay_no_op_verified"] is True + assert evidence["durable_candidate_persisted_to_gda_control"] is False + assert evidence["durable_catalog_verified"] is False + assert evidence["production_ready"] is False + + +def test_evidence_integrity_rejects_runtime_tampering_and_overclaim(): + if not durable.DEFAULT_EVIDENCE_PATH.is_file(): + pytest.skip("checked M3-20 evidence is generated by the live rehearsal") + evidence = durable._load_json_object(durable.DEFAULT_EVIDENCE_PATH) + evidence["observation"]["runtime_binding"]["service"]["uid"] = ( + "ffffffff-ffff-4fff-8fff-ffffffffffff" + ) + + assert "durable promotion evidence SHA-256 does not match" in ( + durable.verify_evidence_integrity(evidence) + ) + + forged = durable._load_json_object(durable.DEFAULT_EVIDENCE_PATH) + forged["durable_catalog_verified"] = True + stable = {key: value for key, value in forged.items() if key != "evidence_sha256"} + forged["evidence_sha256"] = canonical_json_fingerprint(stable) + assert "durable promotion evidence may not claim durable_catalog_verified" in ( + durable.verify_evidence_integrity(forged) + ) diff --git a/data_agent/test_platform_truth.py b/data_agent/test_platform_truth.py index ff84d6eb..9779fb75 100644 --- a/data_agent/test_platform_truth.py +++ b/data_agent/test_platform_truth.py @@ -280,6 +280,12 @@ def test_repository_source_access_and_runtime_baselines_match(): and item["production_role"] == "local_verification_only" for item in static_report["runtime"]["inventory"] ) + assert any( + item["runtime_id"] + == "metadata_durable_active_metadata_promotion_rehearsal" + and item["production_role"] == "local_verification_only" + for item in static_report["runtime"]["inventory"] + ) def test_runtime_report_detects_unregistered_background_mechanism(tmp_path): diff --git a/docs/architecture-decisions/adr-066-runtime-bound-durable-active-metadata-promotion.md b/docs/architecture-decisions/adr-066-runtime-bound-durable-active-metadata-promotion.md new file mode 100644 index 00000000..5872dbd1 --- /dev/null +++ b/docs/architecture-decisions/adr-066-runtime-bound-durable-active-metadata-promotion.md @@ -0,0 +1,68 @@ +# ADR-066: Runtime-bound durable Active Metadata promotion + +**Status**: Accepted + +**Date**: 2026-07-30 + +**Decision owners**: Data Platform, Metadata Platform, Data Governance, Security, Platform Architecture + +**Related decisions**: [ADR-049](adr-049-tenant-scoped-metadata-fabric-binding-ledger.md) · [ADR-054](adr-054-local-gravitino-jdbc-catalog-restart-continuity.md) · [ADR-065](adr-065-local-active-metadata-binding-reconciliation.md) + +## Context + +M3-19 persisted the exact real Chongqing provider binding, but its Gravitino table used a `memory` catalog. That binding correctly identifies the logical provider object but does not contain provider runtime, service, StatefulSet, cluster or storage identity. Reusing the same natural target in another runtime would therefore not prove that the retained object came from a restart-continuous provider. + +Changing the shared binding schema would invalidate the checked M3-19 contract and its source-bound evidence. Overwriting the M3-19 ledger row would also confuse a verified historical memory projection with a new durability claim. M3-20 therefore needs a separate promotion fact before any durable target can become authoritative. + +## Decision + +### 1. Preserve the M3-19 binding and add a promotion candidate + +M3-20 does not modify `MetadataFabricBinding`, the binding ledger or the M3-19 evidence. It creates a new logical binding for `gda_chongqing_m3_20/lakehouse/cultural_heritage/cultural_districts` and combines it with an observed runtime binding. The resulting promotion candidate includes the source M3-19 binding SHA, immutable ResourceVersion, content SHA, OpenMetadata ref, durable Gravitino ref, logical binding SHA and runtime binding SHA. + +The candidate is evidence only. `binding_schema_changed=false` and `durable_candidate_persisted_to_gda_control=false` remain fixed until a versioned runtime-aware ledger contract is accepted. + +### 2. Bind the provider to stable runtime identity + +The runtime binding includes Docker Desktop cluster UID, namespace UID, Gravitino Service UID, PostgreSQL and Gravitino StatefulSet UIDs, both PVC UIDs and volume names, pinned image IDs, JDBC URI and warehouse URI. Pod UIDs are intentionally excluded from the stable binding because they must rotate during restart. + +Before and after restart, cluster, namespace, Service, StatefulSet, image and PVC identity must be unchanged. PostgreSQL and Gravitino pod UIDs must both change. Any stable identity drift blocks the promotion. + +### 3. Separate bootstrap administration from provider apply + +An ephemeral local service admin creates the isolated metalake, JDBC catalog, schema, Basic user and schema-bounded role. The role permits only `USE_CATALOG`, `USE_SCHEMA` and `CREATE_TABLE`; catalog creation is verified as `403` before and after restart. + +The content-bound promotion plan, PolicyDecision and independent Approval authorize only the table projection. On a fresh isolated catalog the first provider action is a direct table create. It must record exactly one `gravitino.table.create` mutation. Retained OpenMetadata is read-only and must exactly match the M3-19 UUID, FQN, version, governance, content and snapshot. + +### 4. Restart continuity requires zero repair + +The exact apply immediately after create must be `no_op/0 mutations`. PostgreSQL is then restarted and made ready before Gravitino is restarted. The first post-restart apply must also be `no_op/0 mutations`, with identical table projection, Gravitino snapshot, logical binding and promotion candidate. A repair or recreate after restart is a failure, not continuity. + +### 5. Local continuity is not production durability + +The warehouse is a local RWO PVC and Gravitino identity uses its built-in Basic IdP. The namespace and volumes are deleted after the rehearsal. M3-20 therefore proves local JDBC/PVC restart continuity and runtime identity binding only. `durable_catalog_verified`, `production_object_store_verified`, protected workload identity, OIDC, TLS, production ingestion and `production_ready` remain false. + +## Verification + +The local rehearsal recorded: + +- Chongqing ResourceVersion `a6000000-0000-4000-8000-000000000001` and content SHA `fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007`; +- exact retained OpenMetadata UUID `9d043410-02b5-487d-bb70-da5f3969a978` with zero OpenMetadata writes; +- one bounded `gravitino.table.create`, then immediate `no_op/0` replay; +- ordered PostgreSQL/Gravitino pod replacement with stable cluster, namespace, Service, StatefulSet and PVC identities; +- first post-restart replay `no_op/0`, without repair; +- logical binding SHA `8c312db37bfe92e034bcdcb7a3c35847c81e862c74a3437970def1007af42750`; +- runtime binding SHA `a78975311fc34abd76fa41dea581594806b3d18ed364ba518cfc44c4204822f7`; +- promotion candidate SHA `bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1`; +- contract SHA `307f2d4390028589c0f38be859c53826bd149d7f2a133b14488230d4f5ff6eb8` and evidence SHA `53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b`; +- complete namespace, PVC and port-forward cleanup. + +## Consequences + +**Positive**: a real Active Metadata projection is now distinguishable by both logical provider identity and the exact restart-continuous runtime that served it. Historical binding evidence remains valid. + +**Negative**: the candidate is not yet a first-class GDA Control ledger object, and the local file warehouse is not the target production storage architecture. + +**Mitigation**: the next slice should combine this runtime-aware promotion contract with JDBC metadata plus S3-compatible object storage, then define the versioned ledger promotion transaction without weakening M3-19 history. + +**Revisit trigger**: adopt a versioned runtime-aware binding/promotion ledger when protected provider identity, production object storage, backup/PITR, tenant isolation and production reconciliation evidence are available. diff --git a/docs/evidence/metadata-fabric-durable-active-metadata-promotion-2026-07-30.json b/docs/evidence/metadata-fabric-durable-active-metadata-promotion-2026-07-30.json new file mode 100644 index 00000000..7b7119de --- /dev/null +++ b/docs/evidence/metadata-fabric-durable-active-metadata-promotion-2026-07-30.json @@ -0,0 +1,709 @@ +{ + "binding_schema_changed": false, + "bounded_gravitino_projection_verified": true, + "contract_sha256": "307f2d4390028589c0f38be859c53826bd149d7f2a133b14488230d4f5ff6eb8", + "dataset_absolute_path_committed": false, + "dataset_bundle": { + "components": [ + { + "component": ".cpg", + "sha256": "3ad3031f5503a4404af825262ee8232cc04d4ea6683d42c5dd0a2f2a27ac9824", + "size_bytes": 5 + }, + { + "component": ".dbf", + "sha256": "ee7c6c4c6957aea296b69d62118d416e5ee989aa77f7b98cf0fe580874ce5127", + "size_bytes": 44990 + }, + { + "component": ".prj", + "sha256": "b10dbe4d6d1de908d340f892c90b3d31a552630af3742bb515bfe1bd26124f2c", + "size_bytes": 176 + }, + { + "component": ".sbn", + "sha256": "7d0279465b18beec40308717e0ef0ea5701a586bc5c84e6a9aa309d5bc0ec99a", + "size_bytes": 308 + }, + { + "component": ".sbx", + "sha256": "019156149b2c7771ec0dd249c757dd7aa01a98075e246a77f77b080860c57333", + "size_bytes": 124 + }, + { + "component": ".shp", + "sha256": "6ac0d5c8c8db66fc0e2a74d8232b7779bd2454257df14efa2930e3dbc181aed0", + "size_bytes": 283640 + }, + { + "component": ".shp.xml", + "sha256": "8ef222ce1952552b366acf14a996e1c8cbdbe3eed0bb829dacfe7eafd068d948", + "size_bytes": 43100 + }, + { + "component": ".shx", + "sha256": "f3fbb6a7775ca833c066e3a3f2a332f99f979840045909ae187d08dac126a119", + "size_bytes": 260 + } + ], + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "format": "ESRI Shapefile", + "schema": "gda.spatial_dataset_bundle.v1", + "source_label": "chongqing-central-cultural-districts", + "spatial_inventory": { + "bounds": [ + 106.37987914500007, + 29.558008447000077, + 106.59532712300008, + 29.877271985000025 + ], + "crs": { + "authority": "EPSG", + "code": 4490, + "name": "China Geodetic Coordinate System 2000" + }, + "driver": "ESRI Shapefile", + "feature_count": 20, + "field_count": 33, + "geometry_type": "PolygonZ" + } + }, + "dataset_required_in_ci": false, + "dataset_source_committed": false, + "deployment_applied": false, + "durable_candidate_persisted_to_gda_control": false, + "durable_catalog_verified": false, + "environment": "local_docker_desktop", + "errors": [], + "evidence_sha256": "53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b", + "jdbc_restart_evidence_fingerprint": "34792bb47ad71041a87adeb644439bf9b6aa3f4855cdc98782d6e3b4282bf1aa", + "local_durable_active_metadata_promotion_verified": true, + "local_jdbc_catalog_restart_continuity_verified": true, + "local_provider_runtime_identity_bound": true, + "logical_binding_sha256": "8c312db37bfe92e034bcdcb7a3c35847c81e862c74a3437970def1007af42750", + "m319_binding_ledger_untouched": true, + "observation": { + "authorization": { + "action": "metadata_fabric.promote_durable_projection", + "approval_artifact_id": "edb6d3a9-8a0c-5a14-a1f1-e956b30afe35", + "authorization_sha256": "f740177876aaf4202789082017d1af188a8a271c17844f7038d69d060d5eadb6", + "execution_plan_artifact_id": "73d5a16d-0f88-5a89-a07f-e7a0f0955f85", + "policy_decision_artifact_id": "777392dc-0108-5c2c-8a65-396f7eab04a3", + "provider_apply_authorized": true, + "run_id": "a9000000-0000-4000-8000-000000000005" + }, + "bootstrap": { + "admin_authentication_status": 200, + "bounded_authentication_status": 200, + "catalog": "lakehouse", + "catalog_backend": "jdbc", + "catalog_uri": "jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg", + "denied_catalog_create_status": 403, + "material_recorded": false, + "metalake": "gda_chongqing_m3_20", + "role": { + "name": "gda-cultural-district-projector", + "securable_objects": [ + { + "fullName": "lakehouse", + "privileges": [ + { + "condition": "ALLOW", + "name": "USE_CATALOG" + } + ], + "type": "CATALOG" + }, + { + "fullName": "lakehouse.cultural_heritage", + "privileges": [ + { + "condition": "ALLOW", + "name": "CREATE_TABLE" + }, + { + "condition": "ALLOW", + "name": "USE_SCHEMA" + } + ], + "type": "SCHEMA" + } + ] + }, + "schema": "cultural_heritage", + "server_version": "1.3.0", + "warehouse": "file:///var/lib/gravitino/warehouse" + }, + "cluster_uid": "c3c9bbab-2b36-4359-a7ac-e3de194445ab", + "contract": { + "contract_sha256": "307f2d4390028589c0f38be859c53826bd149d7f2a133b14488230d4f5ff6eb8", + "jdbc_restart_evidence_fingerprint": "34792bb47ad71041a87adeb644439bf9b6aa3f4855cdc98782d6e3b4282bf1aa", + "source_m319_evidence_sha256": "e6d0e3ac4e052029dad0c18d0804626a8af61554a54081c37d8cc9a80c55cd33" + }, + "dataset_bundle": { + "components": [ + { + "component": ".cpg", + "sha256": "3ad3031f5503a4404af825262ee8232cc04d4ea6683d42c5dd0a2f2a27ac9824", + "size_bytes": 5 + }, + { + "component": ".dbf", + "sha256": "ee7c6c4c6957aea296b69d62118d416e5ee989aa77f7b98cf0fe580874ce5127", + "size_bytes": 44990 + }, + { + "component": ".prj", + "sha256": "b10dbe4d6d1de908d340f892c90b3d31a552630af3742bb515bfe1bd26124f2c", + "size_bytes": 176 + }, + { + "component": ".sbn", + "sha256": "7d0279465b18beec40308717e0ef0ea5701a586bc5c84e6a9aa309d5bc0ec99a", + "size_bytes": 308 + }, + { + "component": ".sbx", + "sha256": "019156149b2c7771ec0dd249c757dd7aa01a98075e246a77f77b080860c57333", + "size_bytes": 124 + }, + { + "component": ".shp", + "sha256": "6ac0d5c8c8db66fc0e2a74d8232b7779bd2454257df14efa2930e3dbc181aed0", + "size_bytes": 283640 + }, + { + "component": ".shp.xml", + "sha256": "8ef222ce1952552b366acf14a996e1c8cbdbe3eed0bb829dacfe7eafd068d948", + "size_bytes": 43100 + }, + { + "component": ".shx", + "sha256": "f3fbb6a7775ca833c066e3a3f2a332f99f979840045909ae187d08dac126a119", + "size_bytes": 260 + } + ], + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "format": "ESRI Shapefile", + "schema": "gda.spatial_dataset_bundle.v1", + "source_label": "chongqing-central-cultural-districts", + "spatial_inventory": { + "bounds": [ + 106.37987914500007, + 29.558008447000077, + 106.59532712300008, + 29.877271985000025 + ], + "crs": { + "authority": "EPSG", + "code": 4490, + "name": "China Geodetic Coordinate System 2000" + }, + "driver": "ESRI Shapefile", + "feature_count": 20, + "field_count": 33, + "geometry_type": "PolygonZ" + } + }, + "first_apply": { + "gravitino": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "identity": "gda_chongqing_m3_20/lakehouse/cultural_heritage/cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "9a5229e6c3390f12bd0cd6f2e33cd4ec3f72b68aab580520f9bbaf973373740d" + }, + "logical_binding_sha256": "8c312db37bfe92e034bcdcb7a3c35847c81e862c74a3437970def1007af42750", + "mutation_count": 1, + "mutations": [ + "gravitino.table.create" + ], + "promotion_candidate_sha256": "bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1", + "status": "created", + "table_projection": { + "columns": [ + { + "name": "BSM", + "nullable": false, + "type": "string" + }, + { + "name": "geometry", + "nullable": false, + "type": "binary" + } + ], + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "name": "cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001" + }, + "table_projection_sha256": "c1e70559500738f72cd726374a621d899100966b7272747a1eac982720b33dc7" + }, + "immediate_replay": { + "gravitino": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "identity": "gda_chongqing_m3_20/lakehouse/cultural_heritage/cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "9a5229e6c3390f12bd0cd6f2e33cd4ec3f72b68aab580520f9bbaf973373740d" + }, + "logical_binding_sha256": "8c312db37bfe92e034bcdcb7a3c35847c81e862c74a3437970def1007af42750", + "mutation_count": 0, + "mutations": [], + "promotion_candidate_sha256": "bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1", + "status": "no_op", + "table_projection": { + "columns": [ + { + "name": "BSM", + "nullable": false, + "type": "string" + }, + { + "name": "geometry", + "nullable": false, + "type": "binary" + } + ], + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "name": "cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001" + }, + "table_projection_sha256": "c1e70559500738f72cd726374a621d899100966b7272747a1eac982720b33dc7" + }, + "initial_runtime": { + "context": "docker-desktop", + "gravitino": { + "image": "docker.io/gda/gravitino:1.3.0-local-arm64", + "image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "node_name": "desktop-worker", + "pod_name": "gravitino-persistence-0", + "pod_uid": "80088b15-ad37-4526-9ed1-303138b4188b", + "pvc": { + "name": "warehouse-gravitino-persistence-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "588482da-0c2e-435a-8b37-b6422959cf33", + "volume_name": "pvc-588482da-0c2e-435a-8b37-b6422959cf33" + }, + "ready_replicas": 1, + "service_account": "gravitino-persistence", + "service_account_automount_disabled": true, + "statefulset_uid": "fd12737f-8584-4a1a-8197-aeda3630bf8d" + }, + "gravitino_host_image_id": "sha256:d355dc7e92f9e3545d717f3eab2cbdf412115f2b82e1e544d7f6235c1eacd5a5", + "jdbc_driver_mounted": true, + "namespace": { + "name": "gda-metadata-catalog-persistence", + "uid": "846cff50-2f5c-4e55-a0e3-4e50479c43f4" + }, + "postgresql": { + "image": "docker.io/library/postgres:16.10-bookworm", + "image_id": "docker.io/library/postgres@sha256:38471f330eb885e04de130b768d6db4e10469e2311879c7e5c699f6d2d8a1c74", + "node_name": "desktop-worker", + "pod_name": "gravitino-persistence-postgresql-0", + "pod_uid": "35f94a2a-db24-4d15-9fc2-5e5c21963e2b", + "pvc": { + "name": "data-gravitino-persistence-postgresql-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "ce8d0655-08cc-4d10-8dfb-a99538ad066d", + "volume_name": "pvc-ce8d0655-08cc-4d10-8dfb-a99538ad066d" + }, + "ready_replicas": 1, + "service_account": "gravitino-persistence-postgresql", + "service_account_automount_disabled": true, + "statefulset_uid": "c16e7f21-c6c1-4a8a-a0cf-f7b7f128b545" + }, + "service": { + "name": "gravitino-persistence", + "type": "ClusterIP", + "uid": "5d199606-bb57-4e82-b29f-0beec106010b" + }, + "source_schema_sha256": "7a2d605a677a462ca619dba594ce7ebcf500358345560ad084c1b67a25c722df" + }, + "observed_at": "2026-07-30T14:59:53.288775+00:00", + "openmetadata": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "domain_refs": [ + "domain:natural-resources" + ], + "entity_id": "9d043410-02b5-487d-bb70-da5f3969a978", + "entity_version": "0.1", + "fully_qualified_name": "gda_chongqing_m3_18.cultural_heritage.published.cultural_districts", + "owner_refs": [ + "team:data-platform" + ], + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "a3ed5e2195c2f5847b5f5b59d78c8ba547c1f7170b3396cdd56b45f8559b0077", + "tag_refs": [ + "CulturalHeritage.CulturalDistrict", + "Sensitivity.Internal" + ] + }, + "openmetadata_mutation_count": 0, + "plan": { + "apply_plan_sha256": "15761ce927b42e184be109e797681433a917244418fbaa6375dcfda90b790f56", + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "definition_version_id": "a9000000-0000-4000-8000-000000000004", + "gravitino_ref": { + "api_profile": "v1", + "catalog": "lakehouse", + "metalake": "gda_chongqing_m3_20", + "object_type": "table", + "provider": "gravitino", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "schema_name": "cultural_heritage", + "server_version": "1.3.0", + "table_name": "cultural_districts" + }, + "logical_binding_sha256": "8c312db37bfe92e034bcdcb7a3c35847c81e862c74a3437970def1007af42750", + "openmetadata_ref": { + "api_profile": "v1", + "entity_id": "9d043410-02b5-487d-bb70-da5f3969a978", + "entity_type": "table", + "entity_version": "0.1", + "fully_qualified_name": "gda_chongqing_m3_18.cultural_heritage.published.cultural_districts", + "provider": "openmetadata", + "server_version": "1.13.1" + }, + "openmetadata_snapshot_sha256": "a3ed5e2195c2f5847b5f5b59d78c8ba547c1f7170b3396cdd56b45f8559b0077", + "promotion_candidate_sha256": "bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "run_id": "a9000000-0000-4000-8000-000000000005", + "runtime_binding": { + "catalog": { + "backend": "jdbc", + "uri": "jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg", + "warehouse": "file:///var/lib/gravitino/warehouse" + }, + "cluster_uid": "c3c9bbab-2b36-4359-a7ac-e3de194445ab", + "context": "docker-desktop", + "images": { + "gravitino_image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "postgresql_image_id": "docker.io/library/postgres@sha256:38471f330eb885e04de130b768d6db4e10469e2311879c7e5c699f6d2d8a1c74" + }, + "namespace": { + "name": "gda-metadata-catalog-persistence", + "uid": "846cff50-2f5c-4e55-a0e3-4e50479c43f4" + }, + "schema": "gda.provider_runtime_binding.v1", + "service": { + "name": "gravitino-persistence", + "uid": "5d199606-bb57-4e82-b29f-0beec106010b" + }, + "storage": { + "postgresql_pvc_uid": "ce8d0655-08cc-4d10-8dfb-a99538ad066d", + "postgresql_volume_name": "pvc-ce8d0655-08cc-4d10-8dfb-a99538ad066d", + "warehouse_pvc_uid": "588482da-0c2e-435a-8b37-b6422959cf33", + "warehouse_volume_name": "pvc-588482da-0c2e-435a-8b37-b6422959cf33" + }, + "workloads": { + "gravitino_statefulset_uid": "fd12737f-8584-4a1a-8197-aeda3630bf8d", + "postgresql_statefulset_uid": "c16e7f21-c6c1-4a8a-a0cf-f7b7f128b545" + } + }, + "runtime_binding_sha256": "a78975311fc34abd76fa41dea581594806b3d18ed364ba518cfc44c4204822f7", + "schema": "gda.durable_active_metadata_projection_plan.v1", + "source_binding_sha256": "7de24cee9dd50dfeefcc886cf43024f4d92b7650767d71d064fdce19ffccb16b", + "target": { + "catalog": "lakehouse", + "catalog_backend": "jdbc", + "catalog_provider": "lakehouse-iceberg", + "catalog_type": "RELATIONAL", + "jdbc_driver": "org.postgresql.Driver", + "metalake": "gda_chongqing_m3_20", + "schema": "cultural_heritage", + "table": "cultural_districts", + "uri": "jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg", + "warehouse": "file:///var/lib/gravitino/warehouse" + }, + "tenant_id": "metadata-authorization-local", + "writes_to_gda_control": false, + "writes_to_legacy": false + }, + "post_restart_first_replay": { + "gravitino": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "identity": "gda_chongqing_m3_20/lakehouse/cultural_heritage/cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "9a5229e6c3390f12bd0cd6f2e33cd4ec3f72b68aab580520f9bbaf973373740d" + }, + "logical_binding_sha256": "8c312db37bfe92e034bcdcb7a3c35847c81e862c74a3437970def1007af42750", + "mutation_count": 0, + "mutations": [], + "promotion_candidate_sha256": "bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1", + "status": "no_op", + "table_projection": { + "columns": [ + { + "name": "BSM", + "nullable": false, + "type": "string" + }, + { + "name": "geometry", + "nullable": false, + "type": "binary" + } + ], + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "name": "cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001" + }, + "table_projection_sha256": "c1e70559500738f72cd726374a621d899100966b7272747a1eac982720b33dc7" + }, + "post_restart_security": { + "bounded_authentication_status": 200, + "denied_catalog_create_status": 403, + "material_recorded": false, + "role": { + "name": "gda-cultural-district-projector", + "securable_objects": [ + { + "fullName": "lakehouse", + "privileges": [ + { + "condition": "ALLOW", + "name": "USE_CATALOG" + } + ], + "type": "CATALOG" + }, + { + "fullName": "lakehouse.cultural_heritage", + "privileges": [ + { + "condition": "ALLOW", + "name": "CREATE_TABLE" + }, + { + "condition": "ALLOW", + "name": "USE_SCHEMA" + } + ], + "type": "SCHEMA" + } + ] + } + }, + "restart": { + "after": { + "context": "docker-desktop", + "gravitino": { + "image": "docker.io/gda/gravitino:1.3.0-local-arm64", + "image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "node_name": "desktop-worker", + "pod_name": "gravitino-persistence-0", + "pod_uid": "f5b0388a-d1a7-4489-95f8-1e2e5f85a1fe", + "pvc": { + "name": "warehouse-gravitino-persistence-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "588482da-0c2e-435a-8b37-b6422959cf33", + "volume_name": "pvc-588482da-0c2e-435a-8b37-b6422959cf33" + }, + "ready_replicas": 1, + "service_account": "gravitino-persistence", + "service_account_automount_disabled": true, + "statefulset_uid": "fd12737f-8584-4a1a-8197-aeda3630bf8d" + }, + "gravitino_host_image_id": "sha256:d355dc7e92f9e3545d717f3eab2cbdf412115f2b82e1e544d7f6235c1eacd5a5", + "jdbc_driver_mounted": true, + "namespace": { + "name": "gda-metadata-catalog-persistence", + "uid": "846cff50-2f5c-4e55-a0e3-4e50479c43f4" + }, + "postgresql": { + "image": "docker.io/library/postgres:16.10-bookworm", + "image_id": "docker.io/library/postgres@sha256:38471f330eb885e04de130b768d6db4e10469e2311879c7e5c699f6d2d8a1c74", + "node_name": "desktop-worker", + "pod_name": "gravitino-persistence-postgresql-0", + "pod_uid": "028f08f9-f0de-422a-adfc-e5e5e5525bdf", + "pvc": { + "name": "data-gravitino-persistence-postgresql-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "ce8d0655-08cc-4d10-8dfb-a99538ad066d", + "volume_name": "pvc-ce8d0655-08cc-4d10-8dfb-a99538ad066d" + }, + "ready_replicas": 1, + "service_account": "gravitino-persistence-postgresql", + "service_account_automount_disabled": true, + "statefulset_uid": "c16e7f21-c6c1-4a8a-a0cf-f7b7f128b545" + }, + "service": { + "name": "gravitino-persistence", + "type": "ClusterIP", + "uid": "5d199606-bb57-4e82-b29f-0beec106010b" + }, + "source_schema_sha256": "7a2d605a677a462ca619dba594ce7ebcf500358345560ad084c1b67a25c722df" + }, + "before": { + "context": "docker-desktop", + "gravitino": { + "image": "docker.io/gda/gravitino:1.3.0-local-arm64", + "image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "node_name": "desktop-worker", + "pod_name": "gravitino-persistence-0", + "pod_uid": "80088b15-ad37-4526-9ed1-303138b4188b", + "pvc": { + "name": "warehouse-gravitino-persistence-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "588482da-0c2e-435a-8b37-b6422959cf33", + "volume_name": "pvc-588482da-0c2e-435a-8b37-b6422959cf33" + }, + "ready_replicas": 1, + "service_account": "gravitino-persistence", + "service_account_automount_disabled": true, + "statefulset_uid": "fd12737f-8584-4a1a-8197-aeda3630bf8d" + }, + "gravitino_host_image_id": "sha256:d355dc7e92f9e3545d717f3eab2cbdf412115f2b82e1e544d7f6235c1eacd5a5", + "jdbc_driver_mounted": true, + "namespace": { + "name": "gda-metadata-catalog-persistence", + "uid": "846cff50-2f5c-4e55-a0e3-4e50479c43f4" + }, + "postgresql": { + "image": "docker.io/library/postgres:16.10-bookworm", + "image_id": "docker.io/library/postgres@sha256:38471f330eb885e04de130b768d6db4e10469e2311879c7e5c699f6d2d8a1c74", + "node_name": "desktop-worker", + "pod_name": "gravitino-persistence-postgresql-0", + "pod_uid": "35f94a2a-db24-4d15-9fc2-5e5c21963e2b", + "pvc": { + "name": "data-gravitino-persistence-postgresql-0", + "phase": "Bound", + "storage_class": "standard", + "uid": "ce8d0655-08cc-4d10-8dfb-a99538ad066d", + "volume_name": "pvc-ce8d0655-08cc-4d10-8dfb-a99538ad066d" + }, + "ready_replicas": 1, + "service_account": "gravitino-persistence-postgresql", + "service_account_automount_disabled": true, + "statefulset_uid": "c16e7f21-c6c1-4a8a-a0cf-f7b7f128b545" + }, + "service": { + "name": "gravitino-persistence", + "type": "ClusterIP", + "uid": "5d199606-bb57-4e82-b29f-0beec106010b" + }, + "source_schema_sha256": "7a2d605a677a462ca619dba594ce7ebcf500358345560ad084c1b67a25c722df" + } + }, + "runtime_binding": { + "catalog": { + "backend": "jdbc", + "uri": "jdbc:postgresql://gravitino-persistence-postgresql:5432/iceberg", + "warehouse": "file:///var/lib/gravitino/warehouse" + }, + "cluster_uid": "c3c9bbab-2b36-4359-a7ac-e3de194445ab", + "context": "docker-desktop", + "images": { + "gravitino_image_id": "sha256:18e24b43be854dabdc13e96b1019eb3dc691d59cc64e411aa6a3cc49225fe2d3", + "postgresql_image_id": "docker.io/library/postgres@sha256:38471f330eb885e04de130b768d6db4e10469e2311879c7e5c699f6d2d8a1c74" + }, + "namespace": { + "name": "gda-metadata-catalog-persistence", + "uid": "846cff50-2f5c-4e55-a0e3-4e50479c43f4" + }, + "schema": "gda.provider_runtime_binding.v1", + "service": { + "name": "gravitino-persistence", + "uid": "5d199606-bb57-4e82-b29f-0beec106010b" + }, + "storage": { + "postgresql_pvc_uid": "ce8d0655-08cc-4d10-8dfb-a99538ad066d", + "postgresql_volume_name": "pvc-ce8d0655-08cc-4d10-8dfb-a99538ad066d", + "warehouse_pvc_uid": "588482da-0c2e-435a-8b37-b6422959cf33", + "warehouse_volume_name": "pvc-588482da-0c2e-435a-8b37-b6422959cf33" + }, + "workloads": { + "gravitino_statefulset_uid": "fd12737f-8584-4a1a-8197-aeda3630bf8d", + "postgresql_statefulset_uid": "c16e7f21-c6c1-4a8a-a0cf-f7b7f128b545" + } + }, + "runtime_binding_sha256": "a78975311fc34abd76fa41dea581594806b3d18ed364ba518cfc44c4204822f7", + "runtime_checks": { + "gravitino_port_forwards_stopped": true, + "material_recorded": false, + "namespace_absent": true, + "namespace_delete_completed": true, + "openmetadata_port_forward_stopped": true, + "persistent_volumes_retained": false, + "provider_objects_retained": false + }, + "schema": "gda.durable_active_metadata_promotion_observation.v1", + "source_m319_first_readback": { + "binding_candidate_sha256": "7de24cee9dd50dfeefcc886cf43024f4d92b7650767d71d064fdce19ffccb16b", + "gravitino": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "identity": "gda_chongqing_m3_18/iceberg/cultural_heritage/cultural_districts", + "provider_revision": "shapefile-bundle-fd474fd65c8e4a71", + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "c7998917cedb52f91c3ae0695223dbc4f505fd90bf9b78990991a60410c500b1" + }, + "mutation_count": 4, + "mutations": [ + "gravitino.catalog.reset_stale_empty_memory", + "gravitino.catalog.create", + "gravitino.schema.create", + "gravitino.table.create" + ], + "openmetadata": { + "content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "domain_refs": [ + "domain:natural-resources" + ], + "entity_id": "9d043410-02b5-487d-bb70-da5f3969a978", + "entity_version": "0.1", + "fully_qualified_name": "gda_chongqing_m3_18.cultural_heritage.published.cultural_districts", + "owner_refs": [ + "team:data-platform" + ], + "resource_urn": "gda://metadata-authorization-local/dataset/chongqing-cultural-districts", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "snapshot_sha256": "a3ed5e2195c2f5847b5f5b59d78c8ba547c1f7170b3396cdd56b45f8559b0077", + "tag_refs": [ + "CulturalHeritage.CulturalDistrict", + "Sensitivity.Internal" + ] + }, + "status": "created" + } + }, + "oidc_verified": false, + "openmetadata_read_only_verified": true, + "platform_run_succeeded": false, + "post_restart_first_replay_no_op_verified": true, + "pre_restart_replay_no_op_verified": true, + "production_ingestion_verified": false, + "production_object_store_verified": false, + "production_ready": false, + "production_scheduler_submission_verified": false, + "promotion_candidate_sha256": "bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1", + "protected_workload_identity_verified": false, + "provider_minimum_privilege_verified": false, + "real_dataset_resource_version_bound": true, + "resource_version_content_sha256": "fd474fd65c8e4a71da241eb3fd07748ca3b972fbd2d3c32833376dbe71104007", + "resource_version_id": "a6000000-0000-4000-8000-000000000001", + "runtime_binding_sha256": "a78975311fc34abd76fa41dea581594806b3d18ed364ba518cfc44c4204822f7", + "schema": "gda.durable_active_metadata_promotion_evidence.v1", + "source_binding_sha256": "7de24cee9dd50dfeefcc886cf43024f4d92b7650767d71d064fdce19ffccb16b", + "source_m319_evidence_sha256": "e6d0e3ac4e052029dad0c18d0804626a8af61554a54081c37d8cc9a80c55cd33", + "status": "local_durable_active_metadata_promotion_verified", + "tls_verified": false +} diff --git a/docs/roadmap-ar0-platform-truth-2026-07-24.md b/docs/roadmap-ar0-platform-truth-2026-07-24.md index 9d095a0b..23128b50 100644 --- a/docs/roadmap-ar0-platform-truth-2026-07-24.md +++ b/docs/roadmap-ar0-platform-truth-2026-07-24.md @@ -199,7 +199,7 @@ Temporal 继续保持目标组件状态,不在这一包并行接入。OpenMeta 当前完成仅指本地合同、授权 evidence、outbox/callback 代码、数据库成功终局门、托管 worker 代码、默认关闭的部署模板及离线 activation/release preflight、candidate/registry/provenance/artifact-release/live observation evidence gate、合成 golden slice、定向测试、真实 PostgreSQL 16 事务边界和 canonical mainline 治理。`candidate_validated`、`registry_subject_bound`、本地合成 `provenance_verified`、`ready_for_activation`、`ready_for_staging_apply`、`verified_for_staging_apply` 和本地 live collection 都不等于真实镜像已 attested 或 staging 已部署;真实 IAM/OIDC 与 service token 生命周期、首次 GHCR publish/verify、真实 provenance artifact verify、registry-backed live staging revision、worker/callback 扩容运行、golden slice staging 运行链、受保护 release/live evidence provenance、独立 DolphinScheduler metadata PostgreSQL 和真实数据终局证据仍属于 4.7 后续切片。 -### 4.8 Metadata Fabric Bridge M1 + M2 + M3-19(本地 provider binding reconciliation 已验证,生产验证待执行) +### 4.8 Metadata Fabric Bridge M1 + M2 + M3-20(本地 runtime-bound durable promotion 已验证,生产验证待执行) 第八块回到 AR-1 的 metadata control plane,以 [ADR-036](architecture-decisions/adr-036-read-only-metadata-fabric-bridge-contract.md) 固定 OpenMetadata + Gravitino + GDA Control Ledger 的首条 table slice: @@ -239,6 +239,7 @@ Temporal 继续保持目标组件状态,不在这一包并行接入。OpenMeta 34. [ADR-063](architecture-decisions/adr-063-local-authorized-active-metadata-scheduler-delivery.md) 将同一重庆 ResourceVersion 指纹带入 provider-native DolphinScheduler `3.4.2` Shell DAG,先发布/release 无副作用 workflow,再把真实返回的 project/workflow code、version 与 compiled SHA 固化为 execution-plan binding。M3-16 授权原子创建的 command 被既有 `DolphinSchedulerCommandConsumer` 认领并真实提交;provider 回读精确包含 6 个 GDA definition/Run 关联变量,且只找到 1 个匹配实例。终态 `SUCCESS` 被记录为 `submitted/success` 两条 attempt observations 和 1 个 external correlation,PlatformRun 只到 `reconciling`、不进入 `succeeded`;authorization replay 不新增。官方 standalone 容器与临时 PostgreSQL 均清理。contract fingerprint 为 `dcf97c8fa002e9fe6b6bc3a7603ee2ebd5ddb053544801ce35143a095e648edb`,evidence fingerprint 为 `00d4ea062c40f8d97557eadc357a36c6d1ccd56e12a94a44694113681e5d55f4`。该结论只证明本地 scheduler control-plane delivery/read-back;受保护身份、常驻 controller、生产 scheduler metadata/HA、provider apply/mutation/ingestion 与 production readiness 仍为 `false`。 35. [ADR-064](architecture-decisions/adr-064-local-scheduler-triggered-active-metadata-projection-execution.md) 将 M3-17 的真实 dispatch 与 M3-2 provider client 串成单条本地执行链:官方 DolphinScheduler Shell task 经 Docker Desktop host gateway 向短生命周期 executor 发送 1 个内容绑定请求;executor 在内存中验证独立 `metadata_fabric.apply` PolicyDecision/Approval,首次向 OpenMetadata/Gravitino 创建 10 个 projection 层级对象并回读相同重庆 ResourceVersion,随后精确 replay 为 `no_op/0 mutations`。两次 read-back 的 OpenMetadata UUID、Gravitino identity 与 binding candidate 完全一致;scheduler 仍形成 `submitted/success` 两条 observation,PlatformRun 保持 `reconciling`。callback、两条 port-forward、standalone 容器和临时数据库均清理,provider projection 保留。contract fingerprint 为 `a6632ae0edd4d4f3389129a8c07411a8d101ae56fbfc26b03fb0aff6928bb7bd`,evidence fingerprint 为 `397c0f1a29f53935c5508155470c4972cfc50260f0d0686fb48cb3f75519b17b`。该结论不证明 protected identity、provider minimum privilege、Gravitino authentication/TLS、生产 scheduler/executor、持久 binding、production ingestion 或 `production_ready`。 36. [ADR-065](architecture-decisions/adr-065-local-active-metadata-binding-reconciliation.md) 在提交 M3-18 binding 前先验证 retained OpenMetadata UUID/FQN/version/content/governance/snapshot 完全一致。Gravitino `memory` catalog 重启后出现 connector 空状态与 provider entity index 残留的分裂;M3-19 只在专用 catalog 配置精确且可见 schema inventory 为空时执行 provider-native reset,并以 4 个 `gravitino.*` mutations 重建 catalog/schema/table,OpenMetadata 零写入。即时 replay 为 `no_op/0 mutations` 且 binding SHA 仍为 `7de24cee9dd50dfeefcc886cf43024f4d92b7650767d71d064fdce19ffccb16b`。PlatformGateway 首次 binding commit `created=true`、重放 `created=false`、仅 1 行,FORCE RLS、跨租户隔离、append-only 和 direct UPDATE/DELETE 拒绝均通过;Run 保持 `reconciling`,所有临时资源清理。contract fingerprint 为 `012a7c86ba9fe53217e721ff7286b8f2a246b9394efd2999abbcd025e13ac7f5`,evidence fingerprint 为 `e6d0e3ac4e052029dad0c18d0804626a8af61554a54081c37d8cc9a80c55cd33`。`durable_catalog_verified=false`,该结论不证明生产 identity/catalog/executor/binding deployment、production ingestion 或 `production_ready`。 +37. [ADR-066](architecture-decisions/adr-066-runtime-bound-durable-active-metadata-promotion.md) 保持 M3-19 binding schema/ledger/evidence 不变,将同一重庆 ResourceVersion 投影到隔离 JDBC metadata + warehouse PVC 的 Gravitino target,并把 logical provider ref 与 cluster/namespace/Service/StatefulSet/PVC/image identity 组合为独立 promotion candidate。受限 Basic principal 首次只执行 1 个 `gravitino.table.create`;即时 replay 与 PostgreSQL -> Gravitino 有序 restart 后的第一次 replay 都为 `no_op/0 mutations`,两次 Pod UID 变化而稳定 runtime/PVC identity 与 table projection 不变。logical binding SHA 为 `8c312db37bfe92e034bcdcb7a3c35847c81e862c74a3437970def1007af42750`,runtime binding SHA 为 `a78975311fc34abd76fa41dea581594806b3d18ed364ba518cfc44c4204822f7`,promotion candidate SHA 为 `bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1`,contract/evidence SHA 分别为 `307f2d4390028589c0f38be859c53826bd149d7f2a133b14488230d4f5ff6eb8` / `53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b`。candidate 未写 GDA Control,namespace/PVC 已清理;`durable_catalog_verified=false`、`production_object_store_verified=false`、`production_ready=false`。 此处 M1 只证明静态合同和只读 HTTP 边界;M2a 只证明本地 live foundation 与 PVC 重挂载连续性;M2b-1/M2b-2 分别限定在同集群新 PVC 和同集群隔离 repository;M2b-3 的 `local_cross_cluster_recovery_verified=true` 只限定在 `local_same_host_distinct_kubernetes_clusters_external_s3_repository`;M2c-1/M2c-2/M2c-3 分别限定本地 provider metrics、临时双周期 OTel 和单 job scrape recovery;M2c-4/M2d-2 只证明 production observability/NetworkPolicy profile 与 attestation 合同可校验;M2d-1 只证明本地两节点 kindnet 的隔离合成流量;M3-1 的 terminal evidence 与 M3-2 的 PolicyDecision/Approval 仍是 deterministic local fixtures。M3-2 只把 projection 写入本地 provider 并证明 retained target 的单次零写入 replay;M3-3 只把该本地 evidence 对应的 binding 写入临时 GDA Control 账本;M3-4 只向无认证 loopback receiver 发送精确 candidate 并验证 503 后幂等恢复;M3-5 只证明 OpenMetadata 在 provider 强制默认 role 之上的项目新增 grant 限定为 `table/Create`,以及本地 JWT 轮换/吊销和越权拒绝;M3-6 只证明隔离 Gravitino Basic IdP 的 bounded table-create、catalog-create 拒绝、登录轮换/吊销和完整清理;M3-7 只证明 pending production identity profile、profile-bound attestation 和派生 claim 的 fail-closed 合同可校验,没有部署或证明真实身份路径;M3-8 只证明同一 Docker Desktop 集群内 Basic 用户、JDBC metadata 与 file warehouse PVC 在受控 Pod restart 后连续;M3-9 只证明同节点共享 RWO PVC 的 Spark interoperability;M3-10 移除了该共享 PVC,并证明同一 Docker Desktop 主机/集群内 Spark 与 MinIO 的跨节点 S3-compatible 互操作,但不证明生产云对象存储、独立 failure domain、持久 identity binding、Flink 或完整 engine conformance;M3-11 只冻结 provider-neutral production object-store profile、精确 attestation binding 与 fail-closed claims,没有选择 provider、部署 bucket/KMS/policy 或提交真实 attestation;M3-12 只证明同一本地路径的 pre-forward commit failure 不改变可见 table state,随后一次显式重试产生一个新 snapshot/row,且无孤儿 data file;M3-13 只证明单次本地 append 在 provider 200 响应丢失并映射为 commit-state-unknown 后,可以由即时 table readback 判定 committed 且不重提,不覆盖持久 controller、进程崩溃、并发写或任意 mutation;M3-14 只证明 ResourceVersion 注册与 Active Metadata 事件在本地 PostgreSQL 同事务创建,并验证租户/workload scoped claim/retry/complete;M3-15 只证明默认零副本 managed consumer 的代码/部署边界,以及本地 PostgreSQL 中 inert activation request 与 event completion 的原子性;M3-16 只证明本地真实数据 content fingerprint、证据绑定授权与 pending command 的 PostgreSQL 原子性;M3-17 只证明本地 standalone 中既有 consumer/adapter 的真实 submission、精确 correlation read-back 和 provider success observation;M3-18 只证明同一 Docker Desktop 主机上 scheduler 通过 ephemeral HTTP executor 触发 bootstrap-admin/unauthenticated providers 的一次创建和同进程零写 replay;M3-19 只证明同一主机上 exact OpenMetadata + absent Gravitino 的受限修复、即时 no-op replay 和临时 PostgreSQL binding commit,且 `memory` catalog reset 明确不等于 durable catalog recovery。生产持久 binding deployment、ResourceVersion 和 legacy authority 都未切换;生产对象存储、双 provider/生产最小权限、protected workload identity、OIDC、TLS、生产持久 catalog、tenant isolation、真实 receiver/alert/SLO、受保护 provider policy、生产故障注入、source-loss recovery、cancel/reconcile/lineage、完整 Spark/Flink conformance、生产 ingest、四项 production gate 和 `production_ready` 仍为 `false`。 diff --git a/docs/system-of-record-matrix-2026-07-24.md b/docs/system-of-record-matrix-2026-07-24.md index 373dfb23..4f738427 100644 --- a/docs/system-of-record-matrix-2026-07-24.md +++ b/docs/system-of-record-matrix-2026-07-24.md @@ -63,6 +63,7 @@ 18. M3-17 只证明官方 DolphinScheduler `3.4.2` standalone 中既有 adapter/consumer 对精确授权 command 的本地真实 submission、6 个 GDA correlation variables 回读、单实例 `SUCCESS` 与 `submitted/success` attempt evidence;PlatformRun 留在 `reconciling`。本地 workflow/project/instance 是 scheduler control-plane 对象,但没有授权或执行 OpenMetadata、Gravitino、lakehouse、legacy 或源数据 mutation。protected workload identity、独立 scheduler metadata PostgreSQL/HA/backup、常驻 deployment、production submission、provider apply/ingestion、告警/SLO 与 production readiness 仍未验证。 19. M3-18 只证明同一 Docker Desktop 主机上的官方 DolphinScheduler task 经 ephemeral HTTP executor 触发一次独立授权的本地 OpenMetadata/Gravitino projection:首次 10 mutations,精确 replay 为 `no_op/0 mutations`,两次 provider read-back 与 binding candidate 一致,PlatformRun 仍为 `reconciling`。OpenMetadata 使用 bootstrap admin,Gravitino 无认证且为 memory catalog;callback 不是 protected workload identity 或生产服务。生产 scheduler/executor、双 provider minimum privilege/OIDC/TLS、持久 binding/catalog、告警/SLO、production ingestion 与 production readiness 仍未验证。 20. M3-19 只允许在 retained OpenMetadata UUID/FQN/version/content/governance/snapshot 完全匹配时修复缺失 Gravitino target;专用 `memory` catalog 只有配置精确且可见 schema inventory 为空才可 provider-native reset。修复限于 4 个 `gravitino.*` mutations,OpenMetadata 零写入,即时 replay 为 `no_op/0 mutations`,binding 通过临时 PostgreSQL PlatformGateway 幂等追加且 Run 留在 `reconciling`。这不证明 durable catalog、protected identity、生产 executor/scheduler/provider、生产 binding deployment/ingestion 或 terminal success。 +21. M3-20 不修改 M3-19 binding schema、ledger 或 evidence,而是为同一重庆 ResourceVersion 新建 runtime-bound durable promotion candidate。受限 Basic principal 在隔离 JDBC metadata + warehouse PVC target 中只创建一次表;即时 replay 与 PostgreSQL/Gravitino restart 后第一次 replay 均为 `no_op/0 mutations`。cluster/namespace/Service/StatefulSet/PVC/image identity 被绑定且重启前后稳定,Pod UID 必须变化;candidate 未写 GDA Control,namespace/PVC 已清理。这只证明本地 restart continuity,不证明生产 durable catalog/object store、protected identity、OIDC/TLS、生产 ingestion 或 readiness。 ## 已建立的 AR-0/AR-1 entry 证据 @@ -105,6 +106,7 @@ - Metadata Fabric M3-17 已将相同重庆 ResourceVersion fingerprint 带入 provider-native DolphinScheduler binding,使用官方 standalone `3.4.2` 真实创建/release 无副作用 Shell workflow。M3-16 authorization 原子创建的 pending command 被既有 consumer 认领并完成;provider 回读 6 个受控 GDA definition/Run variables、1 个匹配实例和 `SUCCESS`,GDA Control 记录精确 `submitted/success` 两条 observations、1 个 external correlation,Run 保持 `reconciling` 而非 `succeeded`。authorization replay 不新增,临时容器与数据库均清理。contract fingerprint 为 `dcf97c8fa002e9fe6b6bc3a7603ee2ebd5ddb053544801ce35143a095e648edb`,evidence fingerprint 为 `00d4ea062c40f8d97557eadc357a36c6d1ccd56e12a94a44694113681e5d55f4`。`deployment_applied=false`、`production_workload_identity_verified=false`、`provider_apply_authorized=false`、`provider_mutations_executed=false`、`production_scheduler_submission_verified=false`、`production_ingestion_verified=false`、`production_ready=false`。 - Metadata Fabric M3-18 已由同一真实 DolphinScheduler `SUCCESS` 实例触发短生命周期 projection executor;独立 `metadata_fabric.apply` authorization 在 provider 调用前验证。重庆 ResourceVersion fingerprint 被写入 OpenMetadata `gda_chongqing_m3_18.cultural_heritage.published.cultural_districts` 与 Gravitino `gda_chongqing_m3_18/iceberg/cultural_heritage/cultural_districts`;首次 10 mutations,精确 replay 为 `no_op/0 mutations`,两次 read-back 的 OpenMetadata UUID `9d043410-02b5-487d-bb70-da5f3969a978`、Gravitino identity 与 binding SHA `7de24cee9dd50dfeefcc886cf43024f4d92b7650767d71d064fdce19ffccb16b` 一致。callback、两条 port-forward、standalone 容器和临时数据库已清理,Run 保持 `reconciling`。contract fingerprint 为 `a6632ae0edd4d4f3389129a8c07411a8d101ae56fbfc26b03fb0aff6928bb7bd`,evidence fingerprint 为 `397c0f1a29f53935c5508155470c4972cfc50260f0d0686fb48cb3f75519b17b`。`protected_workload_identity_verified=false`、`provider_minimum_privilege_verified=false`、`gravitino_authentication_verified=false`、`production_scheduler_submission_verified=false`、`production_ingestion_verified=false`、`production_ready=false`。 - Metadata Fabric M3-19 已在 retained OpenMetadata snapshot 精确匹配 M3-18 后,识别 Gravitino `memory` connector 空状态与持久 entity index 残留。专用 catalog 配置精确且 visible schema inventory 为零后,scheduler callback 只执行 `gravitino.catalog.reset_stale_empty_memory`、catalog/schema/table create 四次 mutation;OpenMetadata 零写入,立即 replay 为 `no_op/0 mutations`,binding SHA 保持 `7de24cee9dd50dfeefcc886cf43024f4d92b7650767d71d064fdce19ffccb16b`。PlatformGateway 首次 commit `created=true`、重放 `created=false`、仅 1 行,FORCE RLS、跨租户隔离、append-only 和 direct UPDATE/DELETE 拒绝通过;Run 保持 `reconciling`,临时资源均清理。contract fingerprint 为 `012a7c86ba9fe53217e721ff7286b8f2a246b9394efd2999abbcd025e13ac7f5`,evidence fingerprint 为 `e6d0e3ac4e052029dad0c18d0804626a8af61554a54081c37d8cc9a80c55cd33`。`durable_catalog_verified=false`、`production_ingestion_verified=false`、`production_ready=false`。 +- Metadata Fabric M3-20 已将同一重庆 ResourceVersion 的 retained OpenMetadata ref 与新 Gravitino JDBC target 组合为 logical binding,并另行绑定 Docker Desktop cluster UID、namespace/Service/StatefulSet/PVC/image identity。受限 `gda-cultural-district-projector` 只有 `USE_CATALOG`、`USE_SCHEMA`、`CREATE_TABLE`,catalog create 前后均为 403;首次 projection 为 `created/1 gravitino.table.create`,即时 replay 与 PostgreSQL -> Gravitino restart 后第一次 replay 均为 `no_op/0 mutations`,表 projection/snapshot 不变。logical/runtime/promotion SHA 分别为 `8c312db37bfe92e034bcdcb7a3c35847c81e862c74a3437970def1007af42750`、`a78975311fc34abd76fa41dea581594806b3d18ed364ba518cfc44c4204822f7`、`bb6672cb7f98fa53305e17bbca2cb5b3756d4a335a94d79114fb4184273871d1`;contract/evidence SHA 为 `307f2d4390028589c0f38be859c53826bd149d7f2a133b14488230d4f5ff6eb8` / `53773e9417668e03ad3ab2b5c3cdbd627fb3bc397d63c5860755ec5318eebe8b`。candidate 未落 ledger,namespace/PVC/port-forward 已清理;`durable_catalog_verified=false`、`production_object_store_verified=false`、`production_ready=false`。 ## 下一验收证据 diff --git a/scripts/metadata-fabric-durable-active-metadata-promotion.sh b/scripts/metadata-fabric-durable-active-metadata-promotion.sh new file mode 100755 index 00000000..a89f7912 --- /dev/null +++ b/scripts/metadata-fabric-durable-active-metadata-promotion.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMMON_GIT_DIR="$(git -C "$ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" +SHARED_ROOT="" +if [ -n "$COMMON_GIT_DIR" ]; then + SHARED_ROOT="$(cd "$COMMON_GIT_DIR/.." && pwd)" +fi + +if [ -n "${PYTHON:-}" ]; then + : +elif [ -x "$ROOT/.venv/bin/python" ]; then + PYTHON="$ROOT/.venv/bin/python" +elif [ -n "$SHARED_ROOT" ] && [ -x "$SHARED_ROOT/.venv/bin/python" ]; then + PYTHON="$SHARED_ROOT/.venv/bin/python" +else + PYTHON="python" +fi + +cd "$ROOT" +exec "$PYTHON" -m data_agent.metadata_fabric_durable_active_metadata_promotion "$@"