|
|
@@ -0,0 +1,347 @@
|
|
|
+"""Phase-three delivery and legacy projection contracts.
|
|
|
+
|
|
|
+The legacy canonical graph deliberately contains no database or candidate IDs. It
|
|
|
+is the single content identity used by the Root dry-run and publication readback.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import hashlib
|
|
|
+from collections.abc import Mapping
|
|
|
+from dataclasses import dataclass, replace
|
|
|
+from typing import Any, Self
|
|
|
+
|
|
|
+from .canonical_json import canonical_json_bytes, canonical_sha256, normalize_json
|
|
|
+from .digests import Sha256Digest
|
|
|
+from .errors import LegacyCanonicalIdentityConflict, ProtocolViolation
|
|
|
+from .phase_two_artifacts import StructuredScriptArtifactV1
|
|
|
+
|
|
|
+MAX_ROOT_DELIVERY_MANIFEST_BYTES = 64 * 1024
|
|
|
+MAX_BUILD_SUMMARY_CHARS = 2_000
|
|
|
+MAX_BLOCKING_DEFECTS = 50
|
|
|
+_ARTIFACT_PREFIX = "script-build://artifact-versions/"
|
|
|
+
|
|
|
+
|
|
|
+def _artifact_uri(value: str, label: str) -> None:
|
|
|
+ suffix = value.removeprefix(_ARTIFACT_PREFIX)
|
|
|
+ if not value.startswith(_ARTIFACT_PREFIX) or not suffix.isdigit():
|
|
|
+ raise ProtocolViolation(f"{label} must pin one immutable artifact version")
|
|
|
+
|
|
|
+
|
|
|
+def root_delivery_input_closure_digest(
|
|
|
+ *,
|
|
|
+ direction_ref: str,
|
|
|
+ direction_digest: str,
|
|
|
+ candidate_portfolio_ref: str,
|
|
|
+ candidate_portfolio_digest: str,
|
|
|
+ structured_script_ref: str,
|
|
|
+ structured_script_digest: str,
|
|
|
+) -> str:
|
|
|
+ """Bind the three immutable Root inputs without conflating their own lineages."""
|
|
|
+
|
|
|
+ for label, ref in (
|
|
|
+ ("direction_ref", direction_ref),
|
|
|
+ ("candidate_portfolio_ref", candidate_portfolio_ref),
|
|
|
+ ("structured_script_ref", structured_script_ref),
|
|
|
+ ):
|
|
|
+ _artifact_uri(ref, label)
|
|
|
+ for digest in (
|
|
|
+ direction_digest,
|
|
|
+ candidate_portfolio_digest,
|
|
|
+ structured_script_digest,
|
|
|
+ ):
|
|
|
+ Sha256Digest.from_wire(digest)
|
|
|
+ return canonical_sha256(
|
|
|
+ {
|
|
|
+ "schema_version": "root-delivery-input-closure/v1",
|
|
|
+ "direction": {"ref": direction_ref, "digest": direction_digest},
|
|
|
+ "candidate_portfolio": {
|
|
|
+ "ref": candidate_portfolio_ref,
|
|
|
+ "digest": candidate_portfolio_digest,
|
|
|
+ },
|
|
|
+ "structured_script": {
|
|
|
+ "ref": structured_script_ref,
|
|
|
+ "digest": structured_script_digest,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ ).wire
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
+class LegacyProjectionCanonicalV1:
|
|
|
+ """Physical-ID-independent business graph exposed by legacy detail reads."""
|
|
|
+
|
|
|
+ direction: str | None
|
|
|
+ summary: str | None
|
|
|
+ paragraphs: tuple[dict[str, Any], ...]
|
|
|
+ elements: tuple[dict[str, Any], ...]
|
|
|
+ paragraph_element_links: tuple[dict[str, Any], ...]
|
|
|
+ paragraph_count: int
|
|
|
+ element_count: int
|
|
|
+ link_count: int
|
|
|
+ schema_version: str = "legacy-projection-canonical/v1"
|
|
|
+
|
|
|
+ def __post_init__(self) -> None:
|
|
|
+ if self.schema_version != "legacy-projection-canonical/v1":
|
|
|
+ raise ProtocolViolation("unsupported legacy projection canonical schema")
|
|
|
+ if self.paragraph_count != len(self.paragraphs):
|
|
|
+ raise ProtocolViolation("canonical paragraph count does not match its graph")
|
|
|
+ if self.element_count != len(self.elements):
|
|
|
+ raise ProtocolViolation("canonical element count does not match its graph")
|
|
|
+ if self.link_count != len(self.paragraph_element_links):
|
|
|
+ raise ProtocolViolation("canonical link count does not match its graph")
|
|
|
+
|
|
|
+ def content_payload(self) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "schema_version": self.schema_version,
|
|
|
+ "direction": self.direction,
|
|
|
+ "summary": self.summary,
|
|
|
+ "paragraph_count": self.paragraph_count,
|
|
|
+ "element_count": self.element_count,
|
|
|
+ "link_count": self.link_count,
|
|
|
+ "paragraphs": list(self.paragraphs),
|
|
|
+ "elements": list(self.elements),
|
|
|
+ "paragraph_element_links": list(self.paragraph_element_links),
|
|
|
+ }
|
|
|
+
|
|
|
+ @property
|
|
|
+ def canonical_sha256(self) -> str:
|
|
|
+ return canonical_sha256(self.content_payload()).wire
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def from_structured_script(
|
|
|
+ cls,
|
|
|
+ script: StructuredScriptArtifactV1,
|
|
|
+ *,
|
|
|
+ direction: str | None = None,
|
|
|
+ summary: str | None = None,
|
|
|
+ ) -> LegacyProjectionCanonicalV1:
|
|
|
+ return canonicalize_legacy_projection(script, direction=direction, summary=summary)
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
+class RootDeliveryManifestV1:
|
|
|
+ direction_ref: str
|
|
|
+ candidate_portfolio_ref: str
|
|
|
+ structured_script_ref: str
|
|
|
+ input_closure_digest: str
|
|
|
+ legacy_projection_digest: str
|
|
|
+ build_summary: str = ""
|
|
|
+ blocking_defects: tuple[str, ...] = ()
|
|
|
+ canonical_sha256: str = ""
|
|
|
+
|
|
|
+ def __post_init__(self) -> None:
|
|
|
+ for label, value in (
|
|
|
+ ("direction_ref", self.direction_ref),
|
|
|
+ ("candidate_portfolio_ref", self.candidate_portfolio_ref),
|
|
|
+ ("structured_script_ref", self.structured_script_ref),
|
|
|
+ ):
|
|
|
+ _artifact_uri(value, label)
|
|
|
+ Sha256Digest.from_wire(self.input_closure_digest)
|
|
|
+ Sha256Digest.from_wire(self.legacy_projection_digest)
|
|
|
+ if len(self.build_summary) > MAX_BUILD_SUMMARY_CHARS:
|
|
|
+ raise ProtocolViolation("root delivery build summary exceeds 2,000 characters")
|
|
|
+ if len(self.blocking_defects) > MAX_BLOCKING_DEFECTS:
|
|
|
+ raise ProtocolViolation("root delivery may contain at most 50 blocking defects")
|
|
|
+ if any(not isinstance(item, str) or not item.strip() for item in self.blocking_defects):
|
|
|
+ raise ProtocolViolation("root delivery blocking defects must be non-blank strings")
|
|
|
+ if len(set(self.blocking_defects)) != len(self.blocking_defects):
|
|
|
+ raise ProtocolViolation("root delivery blocking defects must be unique")
|
|
|
+ if len(canonical_json_bytes(self.content_payload())) > MAX_ROOT_DELIVERY_MANIFEST_BYTES:
|
|
|
+ raise ProtocolViolation("root delivery manifest exceeds 64 KiB")
|
|
|
+ if self.canonical_sha256:
|
|
|
+ expected = canonical_sha256(self.content_payload()).wire
|
|
|
+ if self.canonical_sha256 != expected:
|
|
|
+ raise ProtocolViolation("root delivery manifest digest does not match its content")
|
|
|
+
|
|
|
+ def content_payload(self) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "schema_version": "root-delivery-manifest/v1",
|
|
|
+ "direction_ref": self.direction_ref,
|
|
|
+ "candidate_portfolio_ref": self.candidate_portfolio_ref,
|
|
|
+ "structured_script_ref": self.structured_script_ref,
|
|
|
+ "input_closure_digest": self.input_closure_digest,
|
|
|
+ "legacy_projection_digest": self.legacy_projection_digest,
|
|
|
+ "build_summary": self.build_summary,
|
|
|
+ "blocking_defects": list(self.blocking_defects),
|
|
|
+ }
|
|
|
+
|
|
|
+ def with_digest(self) -> Self:
|
|
|
+ return replace(self, canonical_sha256=canonical_sha256(self.content_payload()).wire)
|
|
|
+
|
|
|
+ def require_publishable(self) -> None:
|
|
|
+ if self.blocking_defects:
|
|
|
+ raise ProtocolViolation("a validated root delivery cannot contain blocking defects")
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def from_payload(cls, value: Mapping[str, Any]) -> Self:
|
|
|
+ if set(value) - {
|
|
|
+ "schema_version",
|
|
|
+ "direction_ref",
|
|
|
+ "candidate_portfolio_ref",
|
|
|
+ "structured_script_ref",
|
|
|
+ "input_closure_digest",
|
|
|
+ "legacy_projection_digest",
|
|
|
+ "build_summary",
|
|
|
+ "blocking_defects",
|
|
|
+ }:
|
|
|
+ raise ProtocolViolation("root delivery manifest contains unknown fields")
|
|
|
+ if value.get("schema_version") != "root-delivery-manifest/v1":
|
|
|
+ raise ProtocolViolation("unsupported root delivery manifest schema")
|
|
|
+ defects = value.get("blocking_defects", [])
|
|
|
+ if not isinstance(defects, list) or any(not isinstance(item, str) for item in defects):
|
|
|
+ raise ProtocolViolation("root delivery blocking_defects must be a string array")
|
|
|
+ return cls(
|
|
|
+ direction_ref=str(value.get("direction_ref", "")),
|
|
|
+ candidate_portfolio_ref=str(value.get("candidate_portfolio_ref", "")),
|
|
|
+ structured_script_ref=str(value.get("structured_script_ref", "")),
|
|
|
+ input_closure_digest=str(value.get("input_closure_digest", "")),
|
|
|
+ legacy_projection_digest=str(value.get("legacy_projection_digest", "")),
|
|
|
+ build_summary=str(value.get("build_summary", "")),
|
|
|
+ blocking_defects=tuple(defects),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _element_key(value: Mapping[str, Any]) -> str:
|
|
|
+ natural = [
|
|
|
+ str(value.get("dimension_primary", "")),
|
|
|
+ str(value.get("dimension_secondary", "")),
|
|
|
+ str(value.get("name", "")),
|
|
|
+ ]
|
|
|
+ return hashlib.sha256(canonical_json_bytes(natural)).hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+def _assert_parent_graph(parents: Mapping[int, int | None]) -> None:
|
|
|
+ for start in parents:
|
|
|
+ seen: set[int] = set()
|
|
|
+ current: int | None = start
|
|
|
+ while current is not None:
|
|
|
+ if current in seen:
|
|
|
+ raise LegacyCanonicalIdentityConflict("paragraph parent graph contains a cycle")
|
|
|
+ seen.add(current)
|
|
|
+ if current not in parents:
|
|
|
+ raise LegacyCanonicalIdentityConflict("paragraph parent reference is dangling")
|
|
|
+ current = parents[current]
|
|
|
+
|
|
|
+
|
|
|
+def canonicalize_legacy_projection(
|
|
|
+ script: StructuredScriptArtifactV1,
|
|
|
+ *,
|
|
|
+ direction: str | None = None,
|
|
|
+ summary: str | None = None,
|
|
|
+) -> LegacyProjectionCanonicalV1:
|
|
|
+ """Convert a frozen StructuredScript to the one canonical legacy graph."""
|
|
|
+
|
|
|
+ active_paragraphs = [item for item in script.paragraphs if item.is_active]
|
|
|
+ candidate_to_index = {item.paragraph_id: item.paragraph_index for item in active_paragraphs}
|
|
|
+ if len(candidate_to_index) != len(active_paragraphs) or len(
|
|
|
+ set(candidate_to_index.values())
|
|
|
+ ) != len(active_paragraphs):
|
|
|
+ raise LegacyCanonicalIdentityConflict("active paragraph logical identities are duplicated")
|
|
|
+ parents: dict[int, int | None] = {}
|
|
|
+ paragraph_payloads: list[dict[str, Any]] = []
|
|
|
+ for paragraph in active_paragraphs:
|
|
|
+ parent_key = None
|
|
|
+ if paragraph.parent_id is not None:
|
|
|
+ parent_key = candidate_to_index.get(paragraph.parent_id)
|
|
|
+ if parent_key is None:
|
|
|
+ raise LegacyCanonicalIdentityConflict("paragraph parent reference is dangling")
|
|
|
+ parents[paragraph.paragraph_index] = parent_key
|
|
|
+ paragraph_payloads.append(
|
|
|
+ {
|
|
|
+ "logical_key": paragraph.paragraph_index,
|
|
|
+ "paragraph_index": paragraph.paragraph_index,
|
|
|
+ "level": paragraph.level,
|
|
|
+ "parent_logical_key": parent_key,
|
|
|
+ "name": paragraph.name,
|
|
|
+ "content_range": paragraph.content_range,
|
|
|
+ "theme": paragraph.theme,
|
|
|
+ "form": paragraph.form,
|
|
|
+ "function": paragraph.function,
|
|
|
+ "feeling": paragraph.feeling,
|
|
|
+ "theme_elements": list(paragraph.theme_elements),
|
|
|
+ "form_elements": list(paragraph.form_elements),
|
|
|
+ "function_elements": list(paragraph.function_elements),
|
|
|
+ "feeling_elements": list(paragraph.feeling_elements),
|
|
|
+ "description": paragraph.description,
|
|
|
+ "full_description": paragraph.full_description,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ _assert_parent_graph(parents)
|
|
|
+
|
|
|
+ active_elements = [item for item in script.elements if item.is_active]
|
|
|
+ element_keys: dict[int, str] = {}
|
|
|
+ seen_natural: set[tuple[str, str, str]] = set()
|
|
|
+ element_payloads: list[dict[str, Any]] = []
|
|
|
+ for element in active_elements:
|
|
|
+ natural = (element.dimension_primary, element.dimension_secondary, element.name)
|
|
|
+ if natural in seen_natural or element.element_id in element_keys:
|
|
|
+ raise LegacyCanonicalIdentityConflict(
|
|
|
+ "active element natural identities are duplicated"
|
|
|
+ )
|
|
|
+ seen_natural.add(natural)
|
|
|
+ payload = element.to_payload()
|
|
|
+ key = _element_key(payload)
|
|
|
+ element_keys[element.element_id] = key
|
|
|
+ element_payloads.append(
|
|
|
+ {
|
|
|
+ "logical_key": key,
|
|
|
+ "name": element.name,
|
|
|
+ "dimension_primary": element.dimension_primary,
|
|
|
+ "dimension_secondary": element.dimension_secondary,
|
|
|
+ "commonality_analysis": element.commonality_analysis,
|
|
|
+ "topic_support": element.topic_support,
|
|
|
+ "weight_score": element.weight_score,
|
|
|
+ "support_elements": list(element.support_elements),
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ link_payloads: list[dict[str, Any]] = []
|
|
|
+ seen_links: set[tuple[int, str]] = set()
|
|
|
+ for link in script.paragraph_element_links:
|
|
|
+ paragraph_key = candidate_to_index.get(link.paragraph_id)
|
|
|
+ element_key = element_keys.get(link.element_id)
|
|
|
+ if paragraph_key is None or element_key is None:
|
|
|
+ raise LegacyCanonicalIdentityConflict("paragraph-element link is dangling")
|
|
|
+ pair = (paragraph_key, element_key)
|
|
|
+ if pair in seen_links:
|
|
|
+ raise LegacyCanonicalIdentityConflict("paragraph-element logical link is duplicated")
|
|
|
+ seen_links.add(pair)
|
|
|
+ link_payloads.append(
|
|
|
+ {"paragraph_logical_key": paragraph_key, "element_logical_key": element_key}
|
|
|
+ )
|
|
|
+
|
|
|
+ paragraph_payloads.sort(key=lambda item: int(item["logical_key"]))
|
|
|
+ element_payloads.sort(key=lambda item: str(item["logical_key"]))
|
|
|
+ link_payloads.sort(
|
|
|
+ key=lambda item: (int(item["paragraph_logical_key"]), str(item["element_logical_key"]))
|
|
|
+ )
|
|
|
+ return LegacyProjectionCanonicalV1(
|
|
|
+ direction=direction,
|
|
|
+ summary=summary,
|
|
|
+ paragraphs=tuple(normalize_json(item) for item in paragraph_payloads),
|
|
|
+ elements=tuple(normalize_json(item) for item in element_payloads),
|
|
|
+ paragraph_element_links=tuple(normalize_json(item) for item in link_payloads),
|
|
|
+ paragraph_count=len(paragraph_payloads),
|
|
|
+ element_count=len(element_payloads),
|
|
|
+ link_count=len(link_payloads),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def hydrate_root_delivery_manifest(
|
|
|
+ payload: Mapping[str, Any], digest: str = ""
|
|
|
+) -> RootDeliveryManifestV1:
|
|
|
+ value = RootDeliveryManifestV1.from_payload(payload)
|
|
|
+ return replace(value, canonical_sha256=digest) if digest else value
|
|
|
+
|
|
|
+
|
|
|
+__all__ = [
|
|
|
+ "MAX_BLOCKING_DEFECTS",
|
|
|
+ "MAX_BUILD_SUMMARY_CHARS",
|
|
|
+ "MAX_ROOT_DELIVERY_MANIFEST_BYTES",
|
|
|
+ "LegacyProjectionCanonicalV1",
|
|
|
+ "RootDeliveryManifestV1",
|
|
|
+ "canonicalize_legacy_projection",
|
|
|
+ "hydrate_root_delivery_manifest",
|
|
|
+ "root_delivery_input_closure_digest",
|
|
|
+]
|