From d09b87f66b95944b0abaf3ac8ed83100c1a34197 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 18 Aug 2026 14:20:22 -0400 Subject: [PATCH 1/4] feat: enforce signed workflow qualification admission --- openadapt_flow/__main__.py | 30 ++ openadapt_flow/qualification_admission.py | 438 ++++++++++++++++++++++ openadapt_flow/runtime/authorization.py | 47 +++ tests/test_qualification_admission.py | 363 ++++++++++++++++++ tests/test_runner_client_lib.py | 95 ++++- 5 files changed, 971 insertions(+), 2 deletions(-) create mode 100644 openadapt_flow/qualification_admission.py create mode 100644 tests/test_qualification_admission.py diff --git a/openadapt_flow/__main__.py b/openadapt_flow/__main__.py index 68e1b17b..67a445d8 100644 --- a/openadapt_flow/__main__.py +++ b/openadapt_flow/__main__.py @@ -56,6 +56,7 @@ import argparse import json +import os import sys from contextlib import contextmanager from pathlib import Path @@ -1566,6 +1567,12 @@ def _cmd_run(args: argparse.Namespace) -> int: resolve_execution_profile, ) from openadapt_flow.ir import Workflow + from openadapt_flow.qualification_admission import ( + QualificationAdmissionError, + expected_from_payload, + load_qualification_signer_trust, + verify_qualification_admission, + ) from openadapt_flow.run_gate import ( build_qualification_case_authorization, build_runtime_authorization, @@ -1706,6 +1713,29 @@ def _cmd_run(args: argparse.Namespace) -> int: "run REFUSED: managed dispatch binding is invalid. Nothing was executed." ) return 2 + if authorization.qualification_admission is None: + print( + "run REFUSED: managed production dispatch has no signed " + "qualification admission. Nothing was executed." + ) + return 2 + try: + signer_trust = load_qualification_signer_trust( + os.environ.get("OPENADAPT_QUALIFICATION_SIGNERS_JSON", "") + ) + verify_qualification_admission( + authorization.qualification_admission, + trusted_signers=signer_trust, + expected=expected_from_payload( + authorization.qualification_admission.payload + ), + ) + except QualificationAdmissionError: + print( + "run REFUSED: qualification admission is not signed by an " + "active trusted authority. Nothing was executed." + ) + return 2 local_authorization = build_runtime_authorization( workflow, report, diff --git a/openadapt_flow/qualification_admission.py b/openadapt_flow/qualification_admission.py new file mode 100644 index 00000000..481a8323 --- /dev/null +++ b/openadapt_flow/qualification_admission.py @@ -0,0 +1,438 @@ +"""Signed, expiring admission for one exact qualified workflow version. + +Qualification evidence is an input to this contract. It is not a prose label +and it is not a substitute for the live run gates. The qualification authority +signs one closed payload after it verifies the counted campaign. A runtime +then verifies the signature, validity interval, revocation state, and every +exact deployment binding before it can use the admission. + +The payload contains only identifiers, bounded public contract names, counts, +and SHA-256 digests. Customer values and evidence bodies stay inside their +declared boundary. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from base64 import b64decode, b64encode +from datetime import datetime, timedelta, timezone +from typing import Any, Final, Literal, Mapping +from uuid import UUID + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +SCHEMA: Final[Literal["openadapt.qualification-admission/v1"]] = ( + "openadapt.qualification-admission/v1" +) +SIGNATURE_DOMAIN: Final[bytes] = b"openadapt-qualification-admission-v1\0" +_SHA256_RE = re.compile(r"^[a-f0-9]{64}$") +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$") +_UTC_RE = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" +) +_KEY_ID_RE = re.compile(r"^qa-ed25519-[a-f0-9]{16}$") +MAX_ADMISSION_LIFETIME: Final[timedelta] = timedelta(days=30) + + +class QualificationAdmissionError(ValueError): + """A qualification admission is invalid or does not fit the live run.""" + + +def canonical_json(value: Any) -> bytes: + """Return the cross-language canonical JSON used by Flow and Cloud.""" + + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + + +def contract_sha256(value: Any) -> str: + """Return the unprefixed digest for one canonical admission sub-contract.""" + + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def qualification_signer_key_id(public_key: bytes) -> str: + """Return the stable identifier for one raw Ed25519 public key.""" + + if len(public_key) != 32: + raise ValueError("qualification signer public key is invalid") + return f"qa-ed25519-{hashlib.sha256(public_key).hexdigest()[:16]}" + + +def _parse_utc(value: str, *, field: str) -> datetime: + if not _UTC_RE.fullmatch(value): + raise ValueError(f"{field} must be a canonical UTC timestamp") + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as exc: # pragma: no cover - guarded by the expression + raise ValueError(f"{field} is invalid") from exc + return parsed.astimezone(timezone.utc) + + +def _uuid(value: str, *, field: str) -> str: + try: + parsed = UUID(value) + except ValueError as exc: + raise ValueError(f"{field} must be a canonical UUID") from exc + if str(parsed) != value: + raise ValueError(f"{field} must be a canonical UUID") + return value + + +class QualificationCondition(BaseModel): + """One task and condition with the required counted trial result.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + task: str = Field(min_length=1, max_length=128, pattern=_ID_RE) + condition: str = Field(min_length=1, max_length=128, pattern=_ID_RE) + required_trials: int = Field(ge=3, le=10_000) + observed_trials: int = Field(ge=3, le=10_000) + + @model_validator(mode="after") + def _complete_count(self) -> "QualificationCondition": + if self.observed_trials < self.required_trials: + raise ValueError("observed trials do not satisfy the required count") + return self + + +class QualificationCampaignBinding(BaseModel): + """The exact counted campaign accepted by the qualification authority.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + artifact_sha256: str = Field(pattern=_SHA256_RE) + contract_sha256: str = Field(pattern=_SHA256_RE) + outcomes_sha256: str = Field(pattern=_SHA256_RE) + oracle_id: str = Field(min_length=1, max_length=200, pattern=_ID_RE) + oracle_contract_sha256: str = Field(pattern=_SHA256_RE) + tasks: tuple[QualificationCondition, ...] = Field(min_length=1, max_length=1_000) + failure_taxonomy: tuple[str, ...] = Field(min_length=2, max_length=128) + decision: Literal["admitted"] = "admitted" + + @model_validator(mode="after") + def _closed_campaign(self) -> "QualificationCampaignBinding": + task_keys = tuple((item.task, item.condition) for item in self.tasks) + if task_keys != tuple(sorted(task_keys)) or len(task_keys) != len( + set(task_keys) + ): + raise ValueError("qualification task conditions must be unique and ordered") + if ( + self.failure_taxonomy != tuple(sorted(self.failure_taxonomy)) + or len(self.failure_taxonomy) != len(set(self.failure_taxonomy)) + or any(_ID_RE.fullmatch(item) is None for item in self.failure_taxonomy) + ): + raise ValueError( + "qualification failure taxonomy must be unique and ordered" + ) + required = {"over_halt", "silent_incorrect_success"} + if not required.issubset(self.failure_taxonomy): + raise ValueError( + "qualification failure taxonomy must report silent incorrect " + "success and over-halt" + ) + return self + + +class QualificationIssuer(BaseModel): + """Review workflow identity recorded by the qualification signer.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + key_id: str = Field(min_length=1, max_length=128, pattern=_ID_RE) + workflow: str = Field(min_length=1, max_length=200, pattern=_ID_RE) + ref: str = Field(min_length=1, max_length=200, pattern=_ID_RE) + + +class QualificationAdmissionPayload(BaseModel): + """Closed production admission for one exact workflow version.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: Literal["openadapt.qualification-admission/v1"] = SCHEMA + admission_id: str + tenant_id: str + workflow_id: str + workflow_version_id: str + bundle_version_id: str + runtime_validation_id: str + bundle_artifact_sha256: str = Field(pattern=_SHA256_RE) + bundle_content_digest: str = Field(pattern=_SHA256_RE) + governed_authorization_template_sha256: str = Field(pattern=_SHA256_RE) + application_contract_sha256: str = Field(pattern=_SHA256_RE) + substrate_contract_sha256: str = Field(pattern=_SHA256_RE) + environment_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_environment_sha256: str = Field(pattern=_SHA256_RE) + runtime_contract_sha256: str = Field(pattern=_SHA256_RE) + input_policy_sha256: str = Field(pattern=_SHA256_RE) + action_policy_sha256: str = Field(pattern=_SHA256_RE) + network_policy_sha256: str = Field(pattern=_SHA256_RE) + identity_contract_sha256: str = Field(pattern=_SHA256_RE) + effect_contract_sha256: str = Field(pattern=_SHA256_RE) + operator_contract_sha256: str = Field(pattern=_SHA256_RE) + campaign: QualificationCampaignBinding + issuer: QualificationIssuer + issued_at: str + not_before: str + expires_at: str + + @field_validator( + "admission_id", + "tenant_id", + "workflow_id", + "workflow_version_id", + "bundle_version_id", + "runtime_validation_id", + ) + @classmethod + def _canonical_uuid(cls, value: str, info: Any) -> str: + return _uuid(value, field=info.field_name) + + @model_validator(mode="after") + def _validity_interval(self) -> "QualificationAdmissionPayload": + issued = _parse_utc(self.issued_at, field="issued_at") + not_before = _parse_utc(self.not_before, field="not_before") + expires = _parse_utc(self.expires_at, field="expires_at") + if not issued - timedelta(minutes=5) <= not_before <= issued + timedelta( + minutes=5 + ): + raise ValueError("qualification admission start is outside issue skew") + if expires <= not_before: + raise ValueError("qualification admission expiry must follow its start") + if expires > issued + MAX_ADMISSION_LIFETIME: + raise ValueError("qualification admission lifetime exceeds 30 days") + return self + + def canonical_bytes(self) -> bytes: + return canonical_json(self.model_dump(mode="json")) + + +class QualificationAdmissionEnvelope(BaseModel): + """Signed admission artifact retained by Cloud and verified by Flow.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + payload: QualificationAdmissionPayload + algorithm: Literal["ed25519"] = "ed25519" + signature: str = Field(min_length=88, max_length=88) + + @field_validator("signature") + @classmethod + def _canonical_signature(cls, value: str) -> str: + try: + decoded = b64decode(value, validate=True) + except ValueError as exc: + raise ValueError( + "qualification admission signature must be base64" + ) from exc + if len(decoded) != 64 or b64encode(decoded).decode("ascii") != value: + raise ValueError("qualification admission signature is invalid") + return value + + def artifact_sha256(self) -> str: + return hashlib.sha256(canonical_json(self.model_dump(mode="json"))).hexdigest() + + +class QualificationSignerTrust(BaseModel): + """Locally configured authority rule for one qualification signer.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + public_key: str + allowed_workflows: tuple[str, ...] = Field(min_length=1, max_length=128) + allowed_ref_prefixes: tuple[str, ...] = Field(min_length=1, max_length=128) + + @field_validator("public_key") + @classmethod + def _public_key(cls, value: str) -> str: + try: + decoded = b64decode(value, validate=True) + except ValueError as exc: + raise ValueError("qualification signer public key must be base64") from exc + if len(decoded) != 32 or b64encode(decoded).decode("ascii") != value: + raise ValueError("qualification signer public key is invalid") + return value + + @model_validator(mode="after") + def _ordered_scope(self) -> "QualificationSignerTrust": + for label, values in ( + ("workflow", self.allowed_workflows), + ("ref prefix", self.allowed_ref_prefixes), + ): + if values != tuple(sorted(values)) or len(values) != len(set(values)): + raise ValueError(f"qualification signer {label}s must be ordered") + if any(not value or len(value) > 200 for value in values): + raise ValueError(f"qualification signer {label} is invalid") + return self + + +class QualificationAdmissionExpected(BaseModel): + """Exact live bindings the signed admission must match.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + tenant_id: str + workflow_id: str + workflow_version_id: str + bundle_version_id: str + runtime_validation_id: str + bundle_artifact_sha256: str = Field(pattern=_SHA256_RE) + bundle_content_digest: str = Field(pattern=_SHA256_RE) + governed_authorization_template_sha256: str = Field(pattern=_SHA256_RE) + application_contract_sha256: str = Field(pattern=_SHA256_RE) + substrate_contract_sha256: str = Field(pattern=_SHA256_RE) + environment_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_environment_sha256: str = Field(pattern=_SHA256_RE) + runtime_contract_sha256: str = Field(pattern=_SHA256_RE) + input_policy_sha256: str = Field(pattern=_SHA256_RE) + action_policy_sha256: str = Field(pattern=_SHA256_RE) + network_policy_sha256: str = Field(pattern=_SHA256_RE) + identity_contract_sha256: str = Field(pattern=_SHA256_RE) + effect_contract_sha256: str = Field(pattern=_SHA256_RE) + operator_contract_sha256: str = Field(pattern=_SHA256_RE) + + @field_validator( + "tenant_id", + "workflow_id", + "workflow_version_id", + "bundle_version_id", + "runtime_validation_id", + ) + @classmethod + def _canonical_uuid(cls, value: str, info: Any) -> str: + return _uuid(value, field=info.field_name) + + +_EXPECTED_FIELDS: Final[tuple[str, ...]] = tuple( + QualificationAdmissionExpected.model_fields +) + + +def expected_from_payload( + payload: QualificationAdmissionPayload, +) -> QualificationAdmissionExpected: + """Project the exact fields a separate live observer must reproduce.""" + + return QualificationAdmissionExpected.model_validate( + {field: getattr(payload, field) for field in _EXPECTED_FIELDS} + ) + + +def load_qualification_signer_trust( + value: str | bytes | Mapping[str, Any], +) -> dict[str, QualificationSignerTrust]: + """Strictly load the locally configured qualification signer registry.""" + + try: + raw = json.loads(value) if isinstance(value, (str, bytes)) else dict(value) + except (json.JSONDecodeError, TypeError, ValueError) as exc: + raise QualificationAdmissionError( + "qualification signer trust registry is invalid" + ) from exc + if not isinstance(raw, dict) or not raw: + raise QualificationAdmissionError( + "qualification signer trust registry is unavailable" + ) + parsed: dict[str, QualificationSignerTrust] = {} + try: + for key_id, trust in raw.items(): + if not isinstance(key_id, str) or _KEY_ID_RE.fullmatch(key_id) is None: + raise ValueError("qualification signer key id is invalid") + parsed_trust = QualificationSignerTrust.model_validate(trust) + raw_public_key = b64decode(parsed_trust.public_key, validate=True) + if qualification_signer_key_id(raw_public_key) != key_id: + raise ValueError("qualification signer key id does not match its key") + parsed[key_id] = parsed_trust + except ValueError as exc: + raise QualificationAdmissionError( + "qualification signer trust registry is invalid" + ) from exc + return parsed + + +def sign_qualification_admission( + payload: QualificationAdmissionPayload, + private_key: Ed25519PrivateKey, +) -> QualificationAdmissionEnvelope: + """Sign one already-reviewed qualification decision.""" + + signature = private_key.sign(SIGNATURE_DOMAIN + payload.canonical_bytes()) + return QualificationAdmissionEnvelope( + payload=payload, + signature=b64encode(signature).decode("ascii"), + ) + + +def verify_qualification_admission( + envelope: QualificationAdmissionEnvelope, + *, + trusted_signers: Mapping[str, QualificationSignerTrust], + expected: QualificationAdmissionExpected, + revoked_admission_ids: set[str] | frozenset[str] = frozenset(), + now: datetime | None = None, +) -> str: + """Verify the production admission and return its canonical artifact digest. + + The caller supplies the live values. The function does not infer a missing + identity, policy, runtime, or environment value from the signed record. + """ + + payload = envelope.payload + trust = trusted_signers.get(payload.issuer.key_id) + if trust is None: + raise QualificationAdmissionError("qualification signer is not trusted") + if qualification_signer_key_id( + b64decode(trust.public_key, validate=True) + ) != payload.issuer.key_id: + raise QualificationAdmissionError("qualification signer key id is invalid") + if payload.issuer.workflow not in trust.allowed_workflows: + raise QualificationAdmissionError( + "qualification issuer workflow is not allowed" + ) + if not any( + payload.issuer.ref.startswith(prefix) for prefix in trust.allowed_ref_prefixes + ): + raise QualificationAdmissionError("qualification issuer ref is not allowed") + try: + public_key = Ed25519PublicKey.from_public_bytes( + b64decode(trust.public_key, validate=True) + ) + public_key.verify( + b64decode(envelope.signature, validate=True), + SIGNATURE_DOMAIN + payload.canonical_bytes(), + ) + except (InvalidSignature, ValueError) as exc: + raise QualificationAdmissionError( + "qualification admission signature is invalid" + ) from exc + + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + issued = _parse_utc(payload.issued_at, field="issued_at") + not_before = _parse_utc(payload.not_before, field="not_before") + expires = _parse_utc(payload.expires_at, field="expires_at") + if issued > current + timedelta(minutes=5): + raise QualificationAdmissionError("qualification admission is future-issued") + if current < not_before: + raise QualificationAdmissionError("qualification admission is not active") + if current >= expires: + raise QualificationAdmissionError("qualification admission has expired") + if payload.admission_id in revoked_admission_ids: + raise QualificationAdmissionError("qualification admission is revoked") + + for field in _EXPECTED_FIELDS: + if getattr(payload, field) != getattr(expected, field): + raise QualificationAdmissionError( + f"qualification admission {field} does not match the live run" + ) + return envelope.artifact_sha256() diff --git a/openadapt_flow/runtime/authorization.py b/openadapt_flow/runtime/authorization.py index 58c709eb..ea5eec33 100644 --- a/openadapt_flow/runtime/authorization.py +++ b/openadapt_flow/runtime/authorization.py @@ -20,6 +20,10 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from openadapt_flow.ir import Interstitial, QualifiedEffectRequirement, Step, Workflow +from openadapt_flow.qualification_admission import ( + QualificationAdmissionEnvelope, + contract_sha256, +) from openadapt_flow.traversal import iter_workflow_steps _CONSUMED_IDS: set[str] = set() @@ -222,6 +226,10 @@ class GovernedRunAuthorization(BaseModel): default_factory=tuple ) approval_source: str = "local-cli-explicit-flag" + qualification_admission: QualificationAdmissionEnvelope | None = None + qualification_admission_sha256: str | None = Field( + default=None, pattern="^[a-f0-9]{64}$" + ) qualification_project_id: str | None = None qualification_project_revision: int | None = Field(default=None, ge=1) qualification_project_contract_sha256: str | None = Field( @@ -268,6 +276,16 @@ class GovernedRunAuthorization(BaseModel): @model_validator(mode="after") def _qualification_binding_is_complete(self) -> "GovernedRunAuthorization": + if (self.qualification_admission is None) != ( + self.qualification_admission_sha256 is None + ): + raise ValueError("qualification admission binding is incomplete") + if ( + self.qualification_admission is not None + and self.qualification_admission.artifact_sha256() + != self.qualification_admission_sha256 + ): + raise ValueError("qualification admission digest does not match") requirement_refs = [ (item.step_id, item.actuation_path, item.effect_index) for item in self.qualified_effect_requirements @@ -492,6 +510,35 @@ def validate_workflow(self, workflow: Workflow) -> str | None: ) if workflow.manifest is None: return "governed run authorization requires a sealed manifest" + if self.qualification_admission is not None: + payload = self.qualification_admission.payload + template = workflow.manifest.provenance.governed_authorization_template + if template is None: + return "qualification admission requires a governed template" + effect_contract_digest = contract_sha256( + [ + item.model_dump(mode="json") + for item in template.qualified_effect_requirements + ] + ) + if ( + payload.bundle_content_digest != self.bundle_content_digest + or payload.governed_authorization_template_sha256 + != template.template_sha256 + or payload.environment_contract_sha256 + != template.qualification_environment_contract_sha256 + or payload.input_policy_sha256 + != template.parameter_contract_sha256 + or payload.action_policy_sha256 + != template.qualification_project_contract_sha256 + or payload.identity_contract_sha256 + != template.identity_contract_sha256 + or payload.effect_contract_sha256 != effect_contract_digest + ): + return ( + "qualification admission does not bind the current governed " + "workflow contracts" + ) if self.execution_profile is not None and self.minimum_effect_tier is not None: from openadapt_flow.execution_profiles import required_effect_tier diff --git a/tests/test_qualification_admission.py b/tests/test_qualification_admission.py new file mode 100644 index 00000000..31dde42a --- /dev/null +++ b/tests/test_qualification_admission.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from base64 import b64encode +from datetime import datetime, timedelta, timezone + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from openadapt_flow.qualification_admission import ( + QualificationAdmissionEnvelope, + QualificationAdmissionError, + QualificationAdmissionExpected, + QualificationAdmissionPayload, + QualificationCampaignBinding, + QualificationCondition, + QualificationIssuer, + QualificationSignerTrust, + qualification_signer_key_id, + sign_qualification_admission, + verify_qualification_admission, +) + +NOW = datetime(2026, 8, 18, 18, 0, tzinfo=timezone.utc) +UUIDS = { + "admission_id": "11111111-1111-4111-8111-111111111111", + "tenant_id": "22222222-2222-4222-8222-222222222222", + "workflow_id": "33333333-3333-4333-8333-333333333333", + "workflow_version_id": "44444444-4444-4444-8444-444444444444", + "bundle_version_id": "55555555-5555-4555-8555-555555555555", + "runtime_validation_id": "66666666-6666-4666-8666-666666666666", +} +DIGEST_FIELDS = ( + "bundle_artifact_sha256", + "bundle_content_digest", + "governed_authorization_template_sha256", + "application_contract_sha256", + "substrate_contract_sha256", + "environment_contract_sha256", + "runtime_environment_sha256", + "runtime_contract_sha256", + "input_policy_sha256", + "action_policy_sha256", + "network_policy_sha256", + "identity_contract_sha256", + "effect_contract_sha256", + "operator_contract_sha256", +) + + +def _utc(value: datetime) -> str: + return value.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _private_key() -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes(bytes(range(1, 33))) + + +def _key_id(key: Ed25519PrivateKey | None = None) -> str: + public = (key or _private_key()).public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + return qualification_signer_key_id(public) + + +def _payload(**updates: object) -> QualificationAdmissionPayload: + values: dict[str, object] = { + **UUIDS, + **{ + field: format(index + 1, "x") * 64 + for index, field in enumerate(DIGEST_FIELDS) + }, + "campaign": QualificationCampaignBinding( + artifact_sha256="b" * 64, + contract_sha256="c" * 64, + outcomes_sha256="d" * 64, + oracle_id="qualification-oracle-v1", + oracle_contract_sha256="e" * 64, + tasks=( + QualificationCondition( + task="create-record", + condition="healthy", + required_trials=3, + observed_trials=3, + ), + QualificationCondition( + task="create-record", + condition="wrong-identity", + required_trials=3, + observed_trials=3, + ), + ), + failure_taxonomy=( + "duplicate_effect", + "over_halt", + "silent_incorrect_success", + "wrong_identity", + ), + ), + "issuer": QualificationIssuer( + key_id=_key_id(), + workflow=( + "OpenAdaptAI/openadapt-internal/.github/workflows/" + "production-qualification-admission.yml" + ), + ref="refs/heads/main@0123456789abcdef0123456789abcdef01234567", + ), + "issued_at": _utc(NOW - timedelta(minutes=1)), + "not_before": _utc(NOW - timedelta(minutes=1)), + "expires_at": _utc(NOW + timedelta(days=29)), + } + values.update(updates) + return QualificationAdmissionPayload.model_validate(values) + + +def _expected(payload: QualificationAdmissionPayload) -> QualificationAdmissionExpected: + return QualificationAdmissionExpected.model_validate( + { + field: getattr(payload, field) + for field in QualificationAdmissionExpected.model_fields + } + ) + + +def _trust(key: Ed25519PrivateKey | None = None) -> dict[str, QualificationSignerTrust]: + public = ( + (key or _private_key()) + .public_key() + .public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + ) + return { + _key_id(key): QualificationSignerTrust( + public_key=b64encode(public).decode("ascii"), + allowed_workflows=( + "OpenAdaptAI/openadapt-internal/.github/workflows/" + "production-qualification-admission.yml", + ), + allowed_ref_prefixes=("refs/heads/main@",), + ) + } + + +def _interop_payload() -> QualificationAdmissionPayload: + key = Ed25519PrivateKey.from_private_bytes(bytes(range(32))) + key_id = qualification_signer_key_id( + key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + ) + return QualificationAdmissionPayload.model_validate( + { + "admission_id": "77777777-7777-4777-8777-777777777777", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "workflow_id": "22222222-2222-4222-8222-222222222222", + "workflow_version_id": "33333333-3333-4333-8333-333333333333", + "bundle_version_id": "33333333-3333-4333-8333-333333333333", + "runtime_validation_id": "66666666-6666-4666-8666-666666666666", + "bundle_artifact_sha256": "1" * 64, + "bundle_content_digest": "2" * 64, + "governed_authorization_template_sha256": "3" * 64, + "application_contract_sha256": "4" * 64, + "substrate_contract_sha256": "5" * 64, + "environment_contract_sha256": "6" * 64, + "runtime_environment_sha256": "7" * 64, + "runtime_contract_sha256": "8" * 64, + "input_policy_sha256": "9" * 64, + "action_policy_sha256": "a" * 64, + "network_policy_sha256": "b" * 64, + "identity_contract_sha256": "c" * 64, + "effect_contract_sha256": "d" * 64, + "operator_contract_sha256": "e" * 64, + "campaign": { + "artifact_sha256": "f" * 64, + "contract_sha256": "0" * 64, + "outcomes_sha256": "1" * 64, + "oracle_id": "postcondition-oracle-v1", + "oracle_contract_sha256": "2" * 64, + "tasks": [ + { + "task": "reference-write", + "condition": "standard", + "required_trials": 3, + "observed_trials": 3, + } + ], + "failure_taxonomy": ["over_halt", "silent_incorrect_success"], + "decision": "admitted", + }, + "issuer": { + "key_id": key_id, + "workflow": ( + "OpenAdaptAI/openadapt-internal/.github/workflows/" + "production-qualification-admission.yml" + ), + "ref": "refs/heads/main@0123456789abcdef0123456789abcdef01234567", + }, + "issued_at": "2030-01-01T00:00:00Z", + "not_before": "2030-01-01T00:00:00Z", + "expires_at": "2030-01-31T00:00:00Z", + } + ) + + +def test_signed_admission_accepts_only_the_exact_live_contract() -> None: + payload = _payload() + envelope = sign_qualification_admission(payload, _private_key()) + + digest = verify_qualification_admission( + envelope, + trusted_signers=_trust(), + expected=_expected(payload), + now=NOW, + ) + + assert digest == envelope.artifact_sha256() + assert len(digest) == 64 + + +def test_cloud_and_flow_share_one_canonical_admission_vector() -> None: + envelope = sign_qualification_admission( + _interop_payload(), Ed25519PrivateKey.from_private_bytes(bytes(range(32))) + ) + assert envelope.signature == ( + "pBd6QoXnyUxqiHuanbnuJmen9/+2SjtQLCm7b3RK15tByYvgLLjKT9x1Ogr/" + "c4dI1y+tYlDk/GiBTzq68B9kCg==" + ) + assert envelope.artifact_sha256() == ( + "2e2bce1a8b63053c9a2d9d8c22cf6616fe091b59b693f6a9fe45cc91916e3aff" + ) + + +@pytest.mark.parametrize("field", QualificationAdmissionExpected.model_fields) +def test_every_changed_live_binding_refuses_before_actuation(field: str) -> None: + payload = _payload() + envelope = sign_qualification_admission(payload, _private_key()) + raw = _expected(payload).model_dump(mode="json") + raw[field] = ( + "77777777-7777-4777-8777-777777777777" if field.endswith("_id") else "f" * 64 + ) + + with pytest.raises(QualificationAdmissionError, match=field): + verify_qualification_admission( + envelope, + trusted_signers=_trust(), + expected=QualificationAdmissionExpected.model_validate(raw), + now=NOW, + ) + + +def test_unknown_signer_invalid_signature_expiry_and_revocation_refuse() -> None: + payload = _payload() + envelope = sign_qualification_admission(payload, _private_key()) + expected = _expected(payload) + + with pytest.raises(QualificationAdmissionError, match="not trusted"): + verify_qualification_admission( + envelope, trusted_signers={}, expected=expected, now=NOW + ) + + envelope_with_wrong_signature = sign_qualification_admission( + payload, Ed25519PrivateKey.generate() + ) + with pytest.raises(QualificationAdmissionError, match="signature"): + verify_qualification_admission( + envelope_with_wrong_signature, + trusted_signers=_trust(), + expected=expected, + now=NOW, + ) + + with pytest.raises(QualificationAdmissionError, match="expired"): + verify_qualification_admission( + envelope, + trusted_signers=_trust(), + expected=expected, + now=NOW + timedelta(days=31), + ) + + with pytest.raises(QualificationAdmissionError, match="revoked"): + verify_qualification_admission( + envelope, + trusted_signers=_trust(), + expected=expected, + revoked_admission_ids={payload.admission_id}, + now=NOW, + ) + + +def test_signature_cannot_move_to_a_repaired_bundle_or_new_workflow_version() -> None: + original = _payload() + signed = sign_qualification_admission(original, _private_key()) + repaired = original.model_copy( + update={ + "workflow_version_id": "88888888-8888-4888-8888-888888888888", + "bundle_version_id": "99999999-9999-4999-8999-999999999999", + "bundle_content_digest": "a" * 64, + } + ) + + with pytest.raises(QualificationAdmissionError, match="workflow_version_id"): + verify_qualification_admission( + signed, + trusted_signers=_trust(), + expected=_expected(repaired), + now=NOW, + ) + + +def test_campaign_requires_three_trials_and_mandatory_failure_metrics() -> None: + with pytest.raises(ValueError, match="greater than or equal to 3"): + QualificationCondition( + task="task", condition="condition", required_trials=2, observed_trials=2 + ) + with pytest.raises(ValueError, match="silent incorrect success"): + QualificationCampaignBinding( + artifact_sha256="a" * 64, + contract_sha256="b" * 64, + outcomes_sha256="c" * 64, + oracle_id="oracle", + oracle_contract_sha256="d" * 64, + tasks=( + QualificationCondition( + task="task", + condition="condition", + required_trials=3, + observed_trials=3, + ), + ), + failure_taxonomy=("duplicate_effect", "over_halt"), + ) + + +def test_unknown_fields_and_noncanonical_signature_refuse() -> None: + payload = _payload() + signed = sign_qualification_admission(payload, _private_key()) + raw = signed.model_dump(mode="json") + raw["payload"]["label"] = "production ready" + with pytest.raises(ValueError, match="extra"): + QualificationAdmissionEnvelope.model_validate(raw) + + raw = signed.model_dump(mode="json") + raw["signature"] = raw["signature"][:-2] + "A=" + with pytest.raises(ValueError, match="signature"): + QualificationAdmissionEnvelope.model_validate(raw) + + +def test_validity_and_signer_identity_are_bounded() -> None: + raw = _payload().model_dump(mode="json") + raw["expires_at"] = _utc(NOW + timedelta(days=31)) + with pytest.raises(ValueError, match="30 days"): + QualificationAdmissionPayload.model_validate(raw) + + raw = _payload().model_dump(mode="json") + raw["issued_at"] = "2026-08-18T17:59:00.100Z" + with pytest.raises(ValueError, match="canonical UTC"): + QualificationAdmissionPayload.model_validate(raw) + + assert _key_id() == "qa-ed25519-65b60673d6ed884b" diff --git a/tests/test_runner_client_lib.py b/tests/test_runner_client_lib.py index abd03d87..507100d4 100644 --- a/tests/test_runner_client_lib.py +++ b/tests/test_runner_client_lib.py @@ -14,11 +14,15 @@ import json import re +from base64 import b64encode from dataclasses import replace +from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from openadapt_flow.ir import ( HaltObservation, @@ -28,6 +32,14 @@ StepResult, Workflow, ) +from openadapt_flow.qualification_admission import ( + QualificationAdmissionPayload, + QualificationCampaignBinding, + QualificationCondition, + QualificationIssuer, + qualification_signer_key_id, + sign_qualification_admission, +) from openadapt_flow.runner import commands, evidence, lease from openadapt_flow.runner.config import ( RunnerConfigError, @@ -123,6 +135,71 @@ def mint_authorization( ) +def qualification_admission(): + now = datetime.now(timezone.utc).replace(microsecond=0) + private_key = Ed25519PrivateKey.from_private_bytes(bytes(range(1, 33))) + public_key = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + key_id = qualification_signer_key_id(public_key) + payload = QualificationAdmissionPayload( + admission_id="11111111-1111-4111-8111-111111111111", + tenant_id="22222222-2222-4222-8222-222222222222", + workflow_id="33333333-3333-4333-8333-333333333333", + workflow_version_id="44444444-4444-4444-8444-444444444444", + bundle_version_id="44444444-4444-4444-8444-444444444444", + runtime_validation_id="55555555-5555-4555-8555-555555555555", + bundle_artifact_sha256="1" * 64, + bundle_content_digest="2" * 64, + governed_authorization_template_sha256="3" * 64, + application_contract_sha256="4" * 64, + substrate_contract_sha256="5" * 64, + environment_contract_sha256="6" * 64, + runtime_environment_sha256="7" * 64, + runtime_contract_sha256="8" * 64, + input_policy_sha256="9" * 64, + action_policy_sha256="a" * 64, + network_policy_sha256="b" * 64, + identity_contract_sha256="c" * 64, + effect_contract_sha256="d" * 64, + operator_contract_sha256="e" * 64, + campaign=QualificationCampaignBinding( + artifact_sha256="f" * 64, + contract_sha256="0" * 64, + outcomes_sha256="1" * 64, + oracle_id="test-oracle", + oracle_contract_sha256="2" * 64, + tasks=( + QualificationCondition( + task="test-task", + condition="healthy", + required_trials=3, + observed_trials=3, + ), + ), + failure_taxonomy=("over_halt", "silent_incorrect_success"), + ), + issuer=QualificationIssuer( + key_id=key_id, + workflow="qualification.yml", + ref="refs/heads/main", + ), + issued_at=(now - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"), + not_before=(now - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"), + expires_at=(now + timedelta(hours=1)).isoformat().replace("+00:00", "Z"), + ) + admission = sign_qualification_admission(payload, private_key) + trust = { + key_id: { + "public_key": b64encode(public_key).decode("ascii"), + "allowed_workflows": ["qualification.yml"], + "allowed_ref_prefixes": ["refs/heads/"], + } + } + return admission, trust + + def dispatch_payload(workflow: Workflow, **overrides) -> dict: assert workflow.manifest is not None authorization = overrides.pop("authorization", None) or mint_authorization(workflow) @@ -256,7 +333,7 @@ def test_dispatch_binding_known_vector(self): dispatch_binding_sha256( "11111111-1111-4111-8111-111111111111", authorization ) - == "sha256:367411c4ff350c05d6dad465db3dd1f57e8d47d620d3c0f16b70adec0857047e" + == "sha256:efd01f7c8c56a0df02200d684a5ab6104e47ec769090b2d76eb090624cdcc272" ) def test_dispatch_binding_refuses_changed_run_or_authorization(self, sealed): @@ -1206,8 +1283,13 @@ def test_managed_cli_preserves_exact_cloud_authorization_and_refuses_weaker_one( workflow, bundle = sealed verdict = verified_or_refusal(workflow, config) assert not isinstance(verdict, Refusal) + admission, trust = qualification_admission() standard_authorization = verdict.payload.authorization.model_copy( - update={"execution_profile": "standard"} + update={ + "execution_profile": "standard", + "qualification_admission": admission, + "qualification_admission_sha256": admission.artifact_sha256(), + } ) managed_payload = verdict.payload.model_copy( update={ @@ -1253,6 +1335,15 @@ def test_managed_cli_preserves_exact_cloud_authorization_and_refuses_weaker_one( "build_runtime_authorization", lambda *_args, **_kwargs: standard_authorization, ) + monkeypatch.setenv( + "OPENADAPT_QUALIFICATION_SIGNERS_JSON", + json.dumps(trust, sort_keys=True), + ) + monkeypatch.setattr( + GovernedRunAuthorization, + "validate_workflow", + lambda _self, _workflow: None, + ) seen: list[object] = [] monkeypatch.setattr( cli, From beabee134d73aaf5276a7ef0fc0f8f43ad5d8aba Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 18 Aug 2026 14:34:08 -0400 Subject: [PATCH 2/4] fix: keep admission identity independent --- openadapt_flow/qualification_admission.py | 5 +++++ tests/test_qualification_admission.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/openadapt_flow/qualification_admission.py b/openadapt_flow/qualification_admission.py index 481a8323..0d335ce5 100644 --- a/openadapt_flow/qualification_admission.py +++ b/openadapt_flow/qualification_admission.py @@ -201,6 +201,11 @@ def _canonical_uuid(cls, value: str, info: Any) -> str: @model_validator(mode="after") def _validity_interval(self) -> "QualificationAdmissionPayload": + if self.admission_id == self.runtime_validation_id: + raise ValueError( + "qualification admission identity must be independent from " + "runtime validation identity" + ) issued = _parse_utc(self.issued_at, field="issued_at") not_before = _parse_utc(self.not_before, field="not_before") expires = _parse_utc(self.expires_at, field="expires_at") diff --git a/tests/test_qualification_admission.py b/tests/test_qualification_admission.py index 31dde42a..ed9d15bd 100644 --- a/tests/test_qualification_admission.py +++ b/tests/test_qualification_admission.py @@ -350,6 +350,9 @@ def test_unknown_fields_and_noncanonical_signature_refuse() -> None: def test_validity_and_signer_identity_are_bounded() -> None: + with pytest.raises(ValueError, match="independent from runtime validation"): + _payload(admission_id=UUIDS["runtime_validation_id"]) + raw = _payload().model_dump(mode="json") raw["expires_at"] = _utc(NOW + timedelta(days=31)) with pytest.raises(ValueError, match="30 days"): From 72d8d8c03af82a4dda3a80691c3534ab4b2001af Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 18 Aug 2026 15:09:47 -0400 Subject: [PATCH 3/4] feat: negotiate qualification authority v2 --- openadapt_flow/connector/__init__.py | 7 ++++++- openadapt_flow/connector/client.py | 4 ++-- tests/test_connector.py | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/openadapt_flow/connector/__init__.py b/openadapt_flow/connector/__init__.py index 9f86e246..7e2b6521 100644 --- a/openadapt_flow/connector/__init__.py +++ b/openadapt_flow/connector/__init__.py @@ -19,7 +19,11 @@ ``connector enroll`` is retained for mock/development control planes. """ -from openadapt_flow.connector.client import ConnectorClient, ConnectorClientError +from openadapt_flow.connector.client import ( + MANAGED_QUALIFICATION_AUTHORITY_CAPABILITY, + ConnectorClient, + ConnectorClientError, +) from openadapt_flow.connector.config import ( ConnectorConfigError, ConnectorSettings, @@ -65,6 +69,7 @@ "GroundingModel", "InMemoryCustomerStorage", "LocalCustomerStorage", + "MANAGED_QUALIFICATION_AUTHORITY_CAPABILITY", "build_run_argv", "build_storage", "connector_config_path", diff --git a/openadapt_flow/connector/client.py b/openadapt_flow/connector/client.py index 93e5fb1f..fe411330 100644 --- a/openadapt_flow/connector/client.py +++ b/openadapt_flow/connector/client.py @@ -28,7 +28,7 @@ from openadapt_flow.hosted import HostedError -MANAGED_DELIVERY_AUTHORITY_CAPABILITY = "managed_delivery_authority_v1" +MANAGED_QUALIFICATION_AUTHORITY_CAPABILITY = "managed_qualification_authority_v2" class ConnectorClientError(HostedError): @@ -93,7 +93,7 @@ def poll(self, wait_s: int) -> Optional[dict[str, Any]]: "/api/connector/poll", json={ "wait": wait_s, - "capabilities": [MANAGED_DELIVERY_AUTHORITY_CAPABILITY], + "capabilities": [MANAGED_QUALIFICATION_AUTHORITY_CAPABILITY], }, headers=self._bearer(), ) diff --git a/tests/test_connector.py b/tests/test_connector.py index 79a39a3e..fe55b41a 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -33,6 +33,7 @@ ConnectorSettings, ExecutionResult, InMemoryCustomerStorage, + MANAGED_QUALIFICATION_AUTHORITY_CAPABILITY, build_run_argv, execute_job, load_settings, @@ -762,7 +763,7 @@ def test_full_loop_dispatch_execute_callback_ack(): ) assert result["status"] == "success" assert cp.poll_bodies == [ - {"wait": 0, "capabilities": ["managed_delivery_authority_v1"]} + {"wait": 0, "capabilities": [MANAGED_QUALIFICATION_AUTHORITY_CAPABILITY]} ] # A PHI-free callback carrying the run-scoped token was posted. From 5868efde8020c62cb59f0da35664c083050dde61 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 18 Aug 2026 19:03:25 -0400 Subject: [PATCH 4/4] feat: add the v2 qualification authority contract Add the closed cross-language v2 contract behind the `managed_qualification_authority_v2` capability: - `qualification_admission_v2`: signed admission payload, evidence identity (`evidence_runner_signer_sha256`, `deployment_manifest_sha256`), one strict SemVer grammar, and an exact sorted runtime-component inventory that binds only the artifacts a substrate uses -- web binds the browser engine and wrapper, native binds Capture, the OS observer and wrapper, RDP/Citrix bind Capture, remote transport and wrapper with no UIA claim. - Signer registry with a durable monotonic checkpoint: provisioning-only initializer, required prior checkpoint (a runtime with no durable checkpoint halts), complete same-revision equality, monotonic generation time, and an fstat-based owner/permission recheck after open. Windows fails closed because POSIX modes prove nothing about an ACL. - `qualification_campaign_permit`: signed non-production trial authority that cannot become production authority or report production success. - `terminal_verification_v2`: typed VERIFIED terminal receipt on `report-v5`, bound to the admission, permit chain, run and sequences. Delivery permits must be issued within 60 seconds of their signer registry check. The acceptor re-derives the outcome, receipt and all five evidence manifests from the exact retained report bytes, so an internally consistent proof over foreign projections is refused. Every signed integer is a JavaScript-safe `StrictInt` and every fixed boolean rejects its numeric spelling, so Python and TypeScript verifiers agree on the same canonical bytes. Co-Authored-By: Claude Fable 5 --- openadapt_flow/execution_profiles.py | 20 + openadapt_flow/qualification_admission.py | 17 +- openadapt_flow/qualification_admission_v2.py | 1417 +++++++++++++++++ .../qualification_campaign_permit.py | 283 ++++ openadapt_flow/runtime/authorization.py | 6 +- openadapt_flow/terminal_verification_v2.py | 1380 ++++++++++++++++ tests/test_execution_profiles.py | 17 + tests/test_qualification_admission.py | 10 +- tests/test_qualification_admission_v2.py | 883 ++++++++++ tests/test_run_receipt.py | 260 +++ tests/test_terminal_verification_v2.py | 576 +++++++ 11 files changed, 4854 insertions(+), 15 deletions(-) create mode 100644 openadapt_flow/qualification_admission_v2.py create mode 100644 openadapt_flow/qualification_campaign_permit.py create mode 100644 openadapt_flow/terminal_verification_v2.py create mode 100644 tests/test_qualification_admission_v2.py create mode 100644 tests/test_terminal_verification_v2.py diff --git a/openadapt_flow/execution_profiles.py b/openadapt_flow/execution_profiles.py index c6212d3c..eff5bc8c 100644 --- a/openadapt_flow/execution_profiles.py +++ b/openadapt_flow/execution_profiles.py @@ -167,6 +167,26 @@ def execution_profile_contract( return _CONTRACTS[resolve_execution_profile(value)] +def requires_signed_qualification_admission( + value: ExecutionProfile | str | None, + *, + will_actuate: bool, +) -> bool: + """Return whether this invocation must carry production qualification. + + Recording, compilation, linting, qualification, and report-only simulation + do not cross an actuation boundary. They stay available without production + authority. Demo is the one named execution profile that can actuate without + a signed qualification admission. Every real Standard or Regulated action + requires that admission, independent of whether Cloud or the customer owns + the runner. + """ + + if not will_actuate or value is None: + return False + return execution_profile_contract(value).production + + def required_effect_tier( workflow: Workflow, profile: ExecutionProfile | str, diff --git a/openadapt_flow/qualification_admission.py b/openadapt_flow/qualification_admission.py index 0d335ce5..2807b4d0 100644 --- a/openadapt_flow/qualification_admission.py +++ b/openadapt_flow/qualification_admission.py @@ -34,9 +34,7 @@ SIGNATURE_DOMAIN: Final[bytes] = b"openadapt-qualification-admission-v1\0" _SHA256_RE = re.compile(r"^[a-f0-9]{64}$") _ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$") -_UTC_RE = re.compile( - r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" -) +_UTC_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") _KEY_ID_RE = re.compile(r"^qa-ed25519-[a-f0-9]{16}$") MAX_ADMISSION_LIFETIME: Final[timedelta] = timedelta(days=30) @@ -209,8 +207,10 @@ def _validity_interval(self) -> "QualificationAdmissionPayload": issued = _parse_utc(self.issued_at, field="issued_at") not_before = _parse_utc(self.not_before, field="not_before") expires = _parse_utc(self.expires_at, field="expires_at") - if not issued - timedelta(minutes=5) <= not_before <= issued + timedelta( - minutes=5 + if ( + not issued - timedelta(minutes=5) + <= not_before + <= issued + timedelta(minutes=5) ): raise ValueError("qualification admission start is outside issue skew") if expires <= not_before: @@ -397,9 +397,10 @@ def verify_qualification_admission( trust = trusted_signers.get(payload.issuer.key_id) if trust is None: raise QualificationAdmissionError("qualification signer is not trusted") - if qualification_signer_key_id( - b64decode(trust.public_key, validate=True) - ) != payload.issuer.key_id: + if ( + qualification_signer_key_id(b64decode(trust.public_key, validate=True)) + != payload.issuer.key_id + ): raise QualificationAdmissionError("qualification signer key id is invalid") if payload.issuer.workflow not in trust.allowed_workflows: raise QualificationAdmissionError( diff --git a/openadapt_flow/qualification_admission_v2.py b/openadapt_flow/qualification_admission_v2.py new file mode 100644 index 00000000..76d81620 --- /dev/null +++ b/openadapt_flow/qualification_admission_v2.py @@ -0,0 +1,1417 @@ +"""v2 production qualification contract (coordinated Flow/Cloud upgrade). + +This module holds the closed cross-language models and verification helpers +for the ``managed_qualification_authority_v2`` capability. The v1 module +(:mod:`openadapt_flow.qualification_admission`) remains the envelope that the +current dispatch path verifies; v2 is the frozen contract that replaces it in +the coordinated activation. Both fail closed. + +Design rules that this module enforces: + +* one strict SemVer grammar for every Flow / Playwright / Capture / Desktop + version that a signer binds; +* an exact, sorted runtime-component inventory that binds only the artifacts a + substrate uses (web: browser engine + wrapper; native: Capture + OS observer + + wrapper; RDP/Citrix: Capture + remote transport + wrapper, no UIA claim); +* ``evidence_runner_signer_sha256`` names the Ed25519 evidence signer; +* the admission ID and the runtime-validation ID are separate authorities; +* every cross-language integer is a strict JavaScript-safe integer and every + fixed boolean rejects the numeric ``0`` / ``1`` spellings; +* the signer registry is a durable, monotonic checkpoint, never an in-memory + cache, and a runtime halts when the checkpoint is absent. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import typing +from base64 import b64decode, b64encode +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Final, Literal, Mapping +from uuid import UUID + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictInt, + field_validator, + model_validator, +) + +SCHEMA: Final[Literal["openadapt.qualification-admission/v2"]] = ( + "openadapt.qualification-admission/v2" +) +SIGNATURE_DOMAIN: Final[bytes] = b"openadapt-qualification-admission-v2\0" +EVIDENCE_IDENTITY_DOMAIN: Final[bytes] = ( + b"OpenAdapt production acceptance evidence identity v2\0" +) +SIGNER_REGISTRY_DOMAIN: Final[bytes] = b"OpenAdapt qualification signer registry v2\0" +RUNTIME_COMPONENT_MANIFEST_DOMAIN: Final[bytes] = ( + b"openadapt-runtime-component-manifest-v1\0" +) +ADMITTED_RUNTIME_BUILD_DOMAIN: Final[bytes] = b"openadapt-admitted-runtime-build-v1\0" +ADMISSION_POLICY_DOMAIN: Final[bytes] = ( + b"OpenAdapt production acceptance admission policy v1\0" +) + +_SHA256_RE = re.compile(r"^[a-f0-9]{64}$") +_HEX40_RE = re.compile(r"^[a-f0-9]{40}$") +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,199}$") +_SAFE_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+:\-]{0,127}$") +_SEMVER_RE = re.compile( + r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" +) +_PINNED_IMAGE_RE = re.compile(r"^[a-z0-9][a-z0-9._/:\-]{0,255}@sha256:[a-f0-9]{64}$") +_UTC_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") +_KEY_ID_RE = re.compile(r"^qa-ed25519-[a-f0-9]{16}$") +MAX_ADMISSION_LIFETIME: Final[timedelta] = timedelta(days=30) +MAX_REGISTRY_LIFETIME: Final[timedelta] = timedelta(days=7) +MAX_PERMIT_SNAPSHOT_AGE: Final[timedelta] = timedelta(seconds=60) +JS_MAX_SAFE_INTEGER: Final[int] = 9_007_199_254_740_991 + + +PRODUCTION_ACCEPTANCE_ADMISSION_POLICY: Final[dict[str, Any]] = { + "schema_version": "openadapt.production-acceptance-admission-policy/v1", + "qualification_admission_schema": SCHEMA, + "qualification_campaign_schema": "openadapt.qualification-campaign/v2", + "qualification_trial_schema": "openadapt.qualification-trial-row/v2", + "qualification_receipt_schema": "openadapt.qualification-evidence-receipt/v2", + "admission_signature_domain": "openadapt-qualification-admission-v2\\0", + "receipt_signature_domain": "openadapt-qualification-evidence-receipt-v2\\0", + "issuer_workflow": ( + "OpenAdaptAI/openadapt-internal/.github/workflows/" + "production-qualification-admission.yml" + ), + "issuer_ref_prefix": "refs/heads/main@", + "maximum_lifetime_seconds": 30 * 24 * 60 * 60, + "minimum_trials_per_condition": 3, + "failure_taxonomy": [ + "collateral_effect", + "duplicate_effect", + "healthy_path_model_call", + "operator_intervention", + "over_halt", + "platform_failure", + "safe_halt", + "silent_incorrect_success", + "uncertain_delivery", + "verified", + "wrong_record", + ], + "external_signer_registry_required": True, + "registry_freshness_required_at_actuation": True, + "revocation_check_required": True, + "distinct_admission_and_runtime_ids_required": True, + "shared_evidence_identity_required": True, +} + + +class QualificationAdmissionError(ValueError): + """The v2 production authority is stale, invalid, or inconsistent.""" + + +def canonical_json(value: Any) -> bytes: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json") + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def contract_sha256(value: Any) -> str: + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def _domain_sha256(domain: bytes, value: Any) -> str: + return hashlib.sha256(domain + canonical_json(value)).hexdigest() + + +PRODUCTION_ACCEPTANCE_ADMISSION_POLICY_SHA256: Final[str] = _domain_sha256( + ADMISSION_POLICY_DOMAIN, + PRODUCTION_ACCEPTANCE_ADMISSION_POLICY, +) + + +def _literal_kinds(annotation: Any) -> tuple[bool, bool]: + """Return ``(fixed_bool, fixed_int)`` for a ``Literal[...]`` annotation.""" + + origin = typing.get_origin(annotation) + if origin is Literal: + args = typing.get_args(annotation) + return ( + all(isinstance(arg, bool) for arg in args), + all(isinstance(arg, int) and not isinstance(arg, bool) for arg in args), + ) + if origin is typing.Union or ( + origin is not None and getattr(origin, "__name__", "") == "UnionType" + ): + kinds = [ + _literal_kinds(arg) + for arg in typing.get_args(annotation) + if arg is not type(None) + ] + if kinds and all(kind[0] for kind in kinds): + return (True, False) + if kinds and all(kind[1] for kind in kinds): + return (False, True) + return (False, False) + + +def reject_numeric_boolean_confusion(model: type[BaseModel], data: Any) -> Any: + """Refuse ``1`` for a fixed ``True`` and ``False`` for a fixed ``0``. + + Pydantic accepts a numeric spelling of a fixed boolean and a boolean + spelling of a fixed integer. A cross-language signed record must reject + both so that Python and TypeScript verifiers agree on the same bytes. + """ + + if not isinstance(data, Mapping): + return data + for name, info in model.model_fields.items(): + if name not in data: + continue + value = data[name] + fixed_bool, fixed_int = _literal_kinds(info.annotation) + if fixed_bool and not isinstance(value, bool): + raise ValueError(f"{name} must be a boolean") + if fixed_int and (isinstance(value, bool) or not isinstance(value, int)): + raise ValueError(f"{name} must be an integer") + return data + + +class ClosedSignedModel(BaseModel): + """Frozen, closed model whose fixed booleans and integers are strict.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + @model_validator(mode="before") + @classmethod + def _strict_fixed_scalars(cls, data: Any) -> Any: + return reject_numeric_boolean_confusion(cls, data) + + +def qualification_signer_key_id(public_key: bytes) -> str: + if len(public_key) != 32: + raise ValueError("qualification signer public key is invalid") + return f"qa-ed25519-{hashlib.sha256(public_key).hexdigest()[:16]}" + + +def _parse_utc(value: str, *, field: str) -> datetime: + if not isinstance(value, str) or _UTC_RE.fullmatch(value) is None: + raise ValueError(f"{field} must be a canonical UTC timestamp") + return datetime.fromisoformat(value[:-1] + "+00:00").astimezone(timezone.utc) + + +def _canonical_utc(value: datetime) -> str: + return ( + value.astimezone(timezone.utc) + .isoformat(timespec="seconds") + .replace("+00:00", "Z") + ) + + +def _uuid(value: str, *, field: str) -> str: + try: + parsed = UUID(value) + except ValueError as exc: + raise ValueError(f"{field} must be a canonical UUID") from exc + if str(parsed) != value: + raise ValueError(f"{field} must be a canonical UUID") + return value + + +def _canonical_base64(value: str, *, size: int, field: str) -> bytes: + try: + decoded = b64decode(value, validate=True) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field} must be canonical base64") from exc + if len(decoded) != size or b64encode(decoded).decode("ascii") != value: + raise ValueError(f"{field} is invalid") + return decoded + + +def _strict_semver(value: str, *, field: str) -> str: + match = _SEMVER_RE.fullmatch(value) + if match is None: + raise ValueError(f"{field} must be strict SemVer") + prerelease = match.group(4) + if prerelease is not None and any( + item.isdigit() and len(item) > 1 and item.startswith("0") + for item in prerelease.split(".") + ): + raise ValueError(f"{field} must be strict SemVer") + return value + + +class ManagedBrowserBuild(ClosedSignedModel): + playwright_version: str = Field(pattern=_SAFE_TOKEN_RE) + browser_base_image: str = Field(pattern=_PINNED_IMAGE_RE) + + @field_validator("playwright_version") + @classmethod + def _playwright_semver(cls, value: str) -> str: + return _strict_semver(value, field="Playwright version") + + +class RuntimeComponent(ClosedSignedModel): + name: str = Field(pattern=_ID_RE) + version: str = Field(pattern=_SAFE_TOKEN_RE) + release_commit: str = Field(pattern=_HEX40_RE) + artifact_sha256: str = Field(pattern=_SHA256_RE) + roles: tuple[ + Literal[ + "actuator", + "observer", + "operator_surface", + "orchestrator", + "transport", + ], + ..., + ] = Field(min_length=1, max_length=5) + capabilities: tuple[ + Literal[ + "accessibility_observation", + "atspi_observation", + "browser_actuation", + "browser_observation", + "desktop_operator_surface", + "flow_runtime", + "native_actuation", + "native_capture", + "pixel_observation", + "remote_transport", + "runner_wrapper", + "uia_observation", + ], + ..., + ] = Field(min_length=1, max_length=12) + + @model_validator(mode="after") + def _closed_component(self) -> "RuntimeComponent": + if self.name in { + "openadapt-capture", + "openadapt-desktop", + "openadapt-flow", + }: + _strict_semver(self.version, field=f"{self.name} version") + for label, values in ( + ("roles", self.roles), + ("capabilities", self.capabilities), + ): + if values != tuple(sorted(values)) or len(values) != len(set(values)): + raise ValueError( + f"runtime component {label} must be unique and ordered" + ) + required_role = { + "accessibility_observation": "observer", + "atspi_observation": "observer", + "browser_actuation": "actuator", + "browser_observation": "observer", + "desktop_operator_surface": "operator_surface", + "flow_runtime": "orchestrator", + "native_actuation": "actuator", + "native_capture": "observer", + "pixel_observation": "observer", + "remote_transport": "transport", + "runner_wrapper": "orchestrator", + "uia_observation": "observer", + } + for capability in self.capabilities: + if required_role[capability] not in self.roles: + raise ValueError( + f"runtime capability {capability} lacks its required role" + ) + return self + + +class RuntimeComponentManifest(ClosedSignedModel): + schema_version: Literal["openadapt.runtime-component-manifest/v1"] = ( + "openadapt.runtime-component-manifest/v1" + ) + components: tuple[RuntimeComponent, ...] = Field(min_length=2, max_length=32) + + @model_validator(mode="after") + def _closed_components(self) -> "RuntimeComponentManifest": + names = tuple(item.name for item in self.components) + if names != tuple(sorted(names)) or len(names) != len(set(names)): + raise ValueError("runtime components must be unique and ordered") + flow = next( + (item for item in self.components if item.name == "openadapt-flow"), + None, + ) + if flow is None or "flow_runtime" not in flow.capabilities: + raise ValueError("runtime components must include openadapt-flow") + if not any("actuator" in item.roles for item in self.components): + raise ValueError("runtime components must include an actuator") + if not any("observer" in item.roles for item in self.components): + raise ValueError("runtime components must include an observer") + return self + + def artifact_sha256(self) -> str: + return _domain_sha256(RUNTIME_COMPONENT_MANIFEST_DOMAIN, self) + + +class SubstrateRuntimeBinding(ClosedSignedModel): + os_family: Literal["windows", "macos", "linux"] + transport: Literal["local", "browser", "rdp", "citrix"] + runtime_boundary_sha256: str = Field(pattern=_SHA256_RE) + + +class AdmittedRuntimeBuildIdentity(ClosedSignedModel): + schema_version: Literal["openadapt.admitted-runtime-build/v1"] = ( + "openadapt.admitted-runtime-build/v1" + ) + substrate: Literal["web", "windows", "macos", "linux", "rdp", "citrix"] + flow_version: str = Field(pattern=_SAFE_TOKEN_RE) + flow_release_commit: str = Field(pattern=_HEX40_RE) + flow_wheel_sha256: str = Field(pattern=_SHA256_RE) + runtime_manifest: RuntimeComponentManifest + runtime_manifest_sha256: str = Field(pattern=_SHA256_RE) + runner_build: str = Field(pattern=_SAFE_TOKEN_RE) + runner_artifact_sha256: str = Field(pattern=_SHA256_RE) + managed_browser: ManagedBrowserBuild | None + substrate_runtime: SubstrateRuntimeBinding + + @model_validator(mode="after") + def _closed_runtime_identity(self) -> "AdmittedRuntimeBuildIdentity": + _strict_semver(self.flow_version, field="Flow version") + if self.runner_build != "report-v5": + raise ValueError("production runner must use report-v5") + if self.runtime_manifest_sha256 != self.runtime_manifest.artifact_sha256(): + raise ValueError("runtime manifest digest is invalid") + expected_transport = { + "web": "browser", + "windows": "local", + "macos": "local", + "linux": "local", + "rdp": "rdp", + "citrix": "citrix", + }[self.substrate] + if self.substrate == "web": + if self.managed_browser is None: + raise ValueError("web runtime must bind its managed browser") + elif self.managed_browser is not None: + raise ValueError("non-web runtime cannot bind a managed browser") + if self.substrate in {"windows", "macos", "linux"}: + if self.substrate_runtime.os_family != self.substrate: + raise ValueError("native runtime OS does not match its substrate") + if self.substrate_runtime.transport != expected_transport: + raise ValueError("runtime transport does not match its substrate") + flow = next( + item + for item in self.runtime_manifest.components + if item.name == "openadapt-flow" + ) + if ( + flow.version, + flow.release_commit, + flow.artifact_sha256, + ) != ( + self.flow_version, + self.flow_release_commit, + self.flow_wheel_sha256, + ): + raise ValueError("runtime manifest Flow identity does not match") + wrappers = [ + component + for component in self.runtime_manifest.components + if "runner_wrapper" in component.capabilities + ] + if len(wrappers) != 1: + raise ValueError("runtime manifest must bind exactly one runner wrapper") + wrapper = wrappers[0] + if (wrapper.version, wrapper.artifact_sha256) != ( + self.runner_build, + self.runner_artifact_sha256, + ): + raise ValueError("runtime manifest runner identity does not match") + capabilities = { + capability + for component in self.runtime_manifest.components + for capability in component.capabilities + } + required = { + "flow_runtime", + "runner_wrapper", + } + forbidden: set[str] = set() + if self.substrate == "web": + required.update({"browser_actuation", "browser_observation"}) + forbidden.update( + { + "accessibility_observation", + "atspi_observation", + "native_actuation", + "native_capture", + "remote_transport", + "uia_observation", + } + ) + elif self.substrate in {"windows", "macos", "linux"}: + observer = { + "windows": "uia_observation", + "macos": "accessibility_observation", + "linux": "atspi_observation", + }[self.substrate] + required.update({"native_actuation", "native_capture", observer}) + forbidden.update( + {"browser_actuation", "browser_observation", "remote_transport"} + ) + else: + required.update( + { + "native_actuation", + "native_capture", + "pixel_observation", + "remote_transport", + } + ) + forbidden.update( + { + "accessibility_observation", + "atspi_observation", + "browser_actuation", + "browser_observation", + "uia_observation", + } + ) + if not required.issubset(capabilities): + missing = ", ".join(sorted(required - capabilities)) + raise ValueError(f"runtime manifest lacks required capabilities: {missing}") + if forbidden & capabilities: + invalid = ", ".join(sorted(forbidden & capabilities)) + raise ValueError(f"runtime manifest has invalid capabilities: {invalid}") + if self.substrate != "web" and not any( + component.name == "openadapt-capture" + and "native_capture" in component.capabilities + for component in self.runtime_manifest.components + ): + raise ValueError("native runtime must bind openadapt-capture") + return self + + def artifact_sha256(self) -> str: + return _domain_sha256(ADMITTED_RUNTIME_BUILD_DOMAIN, self) + + +class ProductionAcceptanceEvidenceIdentity(ClosedSignedModel): + schema_version: Literal["openadapt.production-acceptance-evidence-identity/v2"] = ( + "openadapt.production-acceptance-evidence-identity/v2" + ) + runtime_build_identity: AdmittedRuntimeBuildIdentity + evidence_runner_signer_sha256: str = Field(pattern=_SHA256_RE) + deployment_manifest_sha256: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_sha256: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_revision: StrictInt = Field( + ge=1, le=JS_MAX_SAFE_INTEGER + ) + tenant_id: str + workflow_id: str + workflow_version_id: str + workflow_digest: str = Field(pattern=_SHA256_RE) + bundle_version_id: str + bundle_artifact_sha256: str = Field(pattern=_SHA256_RE) + bundle_content_digest: str = Field(pattern=_SHA256_RE) + environment_digest: str = Field(pattern=_SHA256_RE) + governed_authorization_template_sha256: str = Field(pattern=_SHA256_RE) + application_contract_sha256: str = Field(pattern=_SHA256_RE) + substrate_contract_sha256: str = Field(pattern=_SHA256_RE) + environment_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_environment_sha256: str = Field(pattern=_SHA256_RE) + runtime_contract_sha256: str = Field(pattern=_SHA256_RE) + input_policy_sha256: str = Field(pattern=_SHA256_RE) + action_policy_sha256: str = Field(pattern=_SHA256_RE) + network_policy_sha256: str = Field(pattern=_SHA256_RE) + identity_contract_sha256: str = Field(pattern=_SHA256_RE) + effect_contract_sha256: str = Field(pattern=_SHA256_RE) + operator_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_validation_id: str + admission_id: str + campaign_id: str + campaign_contract_sha256: str = Field(pattern=_SHA256_RE) + oracle_contract_sha256: str = Field(pattern=_SHA256_RE) + qualification_campaign_schema: Literal["openadapt.qualification-campaign/v2"] + qualification_trial_schema: Literal["openadapt.qualification-trial-row/v2"] + qualification_receipt_schema: Literal["openadapt.qualification-evidence-receipt/v2"] + admission_policy_sha256: str = Field(pattern=_SHA256_RE) + + @field_validator( + "tenant_id", + "workflow_id", + "workflow_version_id", + "bundle_version_id", + "runtime_validation_id", + "admission_id", + "campaign_id", + ) + @classmethod + def _canonical_uuid(cls, value: str, info: Any) -> str: + return _uuid(value, field=info.field_name) + + @model_validator(mode="after") + def _closed_identity(self) -> "ProductionAcceptanceEvidenceIdentity": + if self.admission_id == self.runtime_validation_id: + raise ValueError("admission and runtime validation identities must differ") + if self.workflow_version_id != self.bundle_version_id: + raise ValueError("workflow and bundle versions must match") + if self.workflow_digest != self.bundle_content_digest: + raise ValueError("workflow digest must match bundle content digest") + if ( + self.admission_policy_sha256 + != PRODUCTION_ACCEPTANCE_ADMISSION_POLICY_SHA256 + ): + raise ValueError("admission policy digest is invalid") + return self + + def artifact_sha256(self) -> str: + return _domain_sha256(EVIDENCE_IDENTITY_DOMAIN, self) + + +class QualificationCondition(ClosedSignedModel): + task: str = Field(min_length=1, max_length=128, pattern=_ID_RE) + condition: str = Field(min_length=1, max_length=128, pattern=_ID_RE) + required_trials: StrictInt = Field(ge=3, le=10_000) + observed_trials: StrictInt = Field(ge=3, le=10_000) + + @model_validator(mode="after") + def _complete_count(self) -> "QualificationCondition": + if self.observed_trials < self.required_trials: + raise ValueError("observed trials do not satisfy the required count") + return self + + +class QualificationCampaignBinding(ClosedSignedModel): + campaign_id: str + artifact_sha256: str = Field(pattern=_SHA256_RE) + contract_sha256: str = Field(pattern=_SHA256_RE) + outcomes_sha256: str = Field(pattern=_SHA256_RE) + oracle_id: str = Field(min_length=1, max_length=200, pattern=_ID_RE) + oracle_contract_sha256: str = Field(pattern=_SHA256_RE) + tasks: tuple[QualificationCondition, ...] = Field(min_length=1, max_length=1_000) + failure_taxonomy: tuple[str, ...] = Field(min_length=2, max_length=128) + decision: Literal["admitted"] = "admitted" + + @field_validator("campaign_id") + @classmethod + def _campaign_uuid(cls, value: str) -> str: + return _uuid(value, field="campaign_id") + + @model_validator(mode="after") + def _closed_campaign(self) -> "QualificationCampaignBinding": + task_keys = tuple((item.task, item.condition) for item in self.tasks) + if task_keys != tuple(sorted(task_keys)) or len(task_keys) != len( + set(task_keys) + ): + raise ValueError("qualification task conditions must be unique and ordered") + if self.failure_taxonomy != tuple( + PRODUCTION_ACCEPTANCE_ADMISSION_POLICY["failure_taxonomy"] + ): + raise ValueError("qualification failure taxonomy does not match policy") + return self + + +class QualificationIssuer(ClosedSignedModel): + key_id: str = Field(pattern=_KEY_ID_RE) + workflow: str = Field(min_length=1, max_length=200, pattern=_ID_RE) + ref: str = Field(min_length=1, max_length=200, pattern=_ID_RE) + + +class QualificationAdmissionPayload(ClosedSignedModel): + schema_version: Literal["openadapt.qualification-admission/v2"] = SCHEMA + admission_id: str + tenant_id: str + workflow_id: str + workflow_version_id: str + bundle_version_id: str + runtime_validation_id: str + bundle_artifact_sha256: str = Field(pattern=_SHA256_RE) + bundle_content_digest: str = Field(pattern=_SHA256_RE) + environment_digest: str = Field(pattern=_SHA256_RE) + governed_authorization_template_sha256: str = Field(pattern=_SHA256_RE) + application_contract_sha256: str = Field(pattern=_SHA256_RE) + substrate_contract_sha256: str = Field(pattern=_SHA256_RE) + environment_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_environment_sha256: str = Field(pattern=_SHA256_RE) + runtime_contract_sha256: str = Field(pattern=_SHA256_RE) + input_policy_sha256: str = Field(pattern=_SHA256_RE) + action_policy_sha256: str = Field(pattern=_SHA256_RE) + network_policy_sha256: str = Field(pattern=_SHA256_RE) + identity_contract_sha256: str = Field(pattern=_SHA256_RE) + effect_contract_sha256: str = Field(pattern=_SHA256_RE) + operator_contract_sha256: str = Field(pattern=_SHA256_RE) + evidence_identity: ProductionAcceptanceEvidenceIdentity + campaign: QualificationCampaignBinding + issuer: QualificationIssuer + issued_at: str + not_before: str + expires_at: str + + @field_validator( + "admission_id", + "tenant_id", + "workflow_id", + "workflow_version_id", + "bundle_version_id", + "runtime_validation_id", + ) + @classmethod + def _canonical_uuid(cls, value: str, info: Any) -> str: + return _uuid(value, field=info.field_name) + + @model_validator(mode="after") + def _closed_payload(self) -> "QualificationAdmissionPayload": + if self.admission_id == self.runtime_validation_id: + raise ValueError( + "qualification admission identity must be independent from " + "runtime validation identity" + ) + issued = _parse_utc(self.issued_at, field="issued_at") + not_before = _parse_utc(self.not_before, field="not_before") + expires = _parse_utc(self.expires_at, field="expires_at") + if ( + not issued - timedelta(minutes=5) + <= not_before + <= issued + timedelta(minutes=5) + ): + raise ValueError("qualification admission start is outside issue skew") + if expires <= not_before: + raise ValueError("qualification admission expiry must follow its start") + if expires > issued + MAX_ADMISSION_LIFETIME: + raise ValueError("qualification admission lifetime exceeds 30 days") + if ( + self.issuer.workflow + != PRODUCTION_ACCEPTANCE_ADMISSION_POLICY["issuer_workflow"] + ): + raise ValueError("qualification issuer workflow does not match policy") + expected_ref_prefix = PRODUCTION_ACCEPTANCE_ADMISSION_POLICY[ + "issuer_ref_prefix" + ] + if ( + re.fullmatch( + re.escape(expected_ref_prefix) + r"[a-f0-9]{40}", self.issuer.ref + ) + is None + ): + raise ValueError("qualification issuer ref does not match policy") + identity = self.evidence_identity + for field in ( + "admission_id", + "tenant_id", + "workflow_id", + "workflow_version_id", + "bundle_version_id", + "runtime_validation_id", + "bundle_artifact_sha256", + "bundle_content_digest", + "environment_digest", + "governed_authorization_template_sha256", + "application_contract_sha256", + "substrate_contract_sha256", + "environment_contract_sha256", + "runtime_environment_sha256", + "runtime_contract_sha256", + "input_policy_sha256", + "action_policy_sha256", + "network_policy_sha256", + "identity_contract_sha256", + "effect_contract_sha256", + "operator_contract_sha256", + ): + if getattr(self, field) != getattr(identity, field): + raise ValueError(f"evidence identity {field} differs from admission") + if ( + identity.campaign_id != self.campaign.campaign_id + or identity.campaign_contract_sha256 != self.campaign.contract_sha256 + or identity.oracle_contract_sha256 != self.campaign.oracle_contract_sha256 + ): + raise ValueError("evidence identity differs from qualification campaign") + return self + + def canonical_bytes(self) -> bytes: + return canonical_json(self) + + +class QualificationAdmissionEnvelope(ClosedSignedModel): + payload: QualificationAdmissionPayload + algorithm: Literal["ed25519"] = "ed25519" + signature: str = Field(min_length=88, max_length=88) + + @field_validator("signature") + @classmethod + def _signature(cls, value: str) -> str: + _canonical_base64(value, size=64, field="qualification admission signature") + return value + + def artifact_sha256(self) -> str: + return contract_sha256(self) + + +class QualificationSignerRegistryEntry(ClosedSignedModel): + algorithm: Literal["ed25519"] + key_id: str = Field(pattern=_KEY_ID_RE) + public_key: str + allowed_workflows: tuple[str, ...] = Field(min_length=1, max_length=128) + allowed_ref_prefixes: tuple[str, ...] = Field(min_length=1, max_length=128) + status: Literal["active", "revoked"] + revoked_at: str | None + + @field_validator("public_key") + @classmethod + def _public_key(cls, value: str) -> str: + _canonical_base64(value, size=32, field="qualification signer public key") + return value + + @model_validator(mode="after") + def _closed_signer(self) -> "QualificationSignerRegistryEntry": + if ( + qualification_signer_key_id(b64decode(self.public_key, validate=True)) + != self.key_id + ): + raise ValueError("qualification signer key id does not match its key") + for label, values in ( + ("workflow", self.allowed_workflows), + ("ref prefix", self.allowed_ref_prefixes), + ): + if values != tuple(sorted(values)) or len(values) != len(set(values)): + raise ValueError(f"qualification signer {label}s must be ordered") + if any(_ID_RE.fullmatch(value) is None for value in values): + raise ValueError(f"qualification signer {label} is invalid") + if self.status == "active" and self.revoked_at is not None: + raise ValueError("active qualification signer has a revocation time") + if self.status == "revoked" and self.revoked_at is None: + raise ValueError("revoked qualification signer lacks a revocation time") + if self.revoked_at is not None: + _parse_utc(self.revoked_at, field="qualification signer revoked_at") + return self + + +class QualificationSignerRegistry(ClosedSignedModel): + schema_version: Literal["openadapt.qualification-signer-registry/v2"] = ( + "openadapt.qualification-signer-registry/v2" + ) + revision: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + generated_at: str + expires_at: str + signers: tuple[QualificationSignerRegistryEntry, ...] = Field( + min_length=1, max_length=128 + ) + + @model_validator(mode="after") + def _closed_registry(self) -> "QualificationSignerRegistry": + generated = _parse_utc(self.generated_at, field="registry generated_at") + expires = _parse_utc(self.expires_at, field="registry expires_at") + if expires <= generated or expires > generated + MAX_REGISTRY_LIFETIME: + raise ValueError("qualification signer registry interval is invalid") + key_ids = tuple(item.key_id for item in self.signers) + if key_ids != tuple(sorted(key_ids)) or len(key_ids) != len(set(key_ids)): + raise ValueError("qualification signer registry must be unique and ordered") + for signer in self.signers: + if ( + signer.revoked_at is not None + and _parse_utc( + signer.revoked_at, field="qualification signer revoked_at" + ) + > generated + ): + raise ValueError("qualification signer revocation time is invalid") + return self + + def artifact_sha256(self) -> str: + return _domain_sha256(SIGNER_REGISTRY_DOMAIN, self) + + +class QualificationAdmissionExpected(ClosedSignedModel): + """Exact values reproduced from live state, not from the signed payload.""" + + tenant_id: str + workflow_id: str + workflow_version_id: str + bundle_version_id: str + runtime_validation_id: str + bundle_artifact_sha256: str = Field(pattern=_SHA256_RE) + bundle_content_digest: str = Field(pattern=_SHA256_RE) + environment_digest: str = Field(pattern=_SHA256_RE) + governed_authorization_template_sha256: str = Field(pattern=_SHA256_RE) + application_contract_sha256: str = Field(pattern=_SHA256_RE) + substrate_contract_sha256: str = Field(pattern=_SHA256_RE) + environment_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_environment_sha256: str = Field(pattern=_SHA256_RE) + runtime_contract_sha256: str = Field(pattern=_SHA256_RE) + input_policy_sha256: str = Field(pattern=_SHA256_RE) + action_policy_sha256: str = Field(pattern=_SHA256_RE) + network_policy_sha256: str = Field(pattern=_SHA256_RE) + identity_contract_sha256: str = Field(pattern=_SHA256_RE) + effect_contract_sha256: str = Field(pattern=_SHA256_RE) + operator_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_build_identity: AdmittedRuntimeBuildIdentity + evidence_runner_signer_sha256: str = Field(pattern=_SHA256_RE) + deployment_manifest_sha256: str = Field(pattern=_SHA256_RE) + + @field_validator( + "tenant_id", + "workflow_id", + "workflow_version_id", + "bundle_version_id", + "runtime_validation_id", + ) + @classmethod + def _canonical_uuid(cls, value: str, info: Any) -> str: + return _uuid(value, field=info.field_name) + + +class QualificationPermitTrustSnapshot(ClosedSignedModel): + """Registry state observed by the authority that issues an action permit.""" + + qualification_signer_registry_sha256: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_revision: StrictInt = Field( + ge=1, le=JS_MAX_SAFE_INTEGER + ) + qualification_signer_registry_checked_at: str + qualification_signer_registry_expires_at: str + + @model_validator(mode="after") + def _canonical_times(self) -> "QualificationPermitTrustSnapshot": + checked = _parse_utc( + self.qualification_signer_registry_checked_at, + field="qualification signer registry checked_at", + ) + expires = _parse_utc( + self.qualification_signer_registry_expires_at, + field="qualification signer registry expires_at", + ) + if checked >= expires: + raise ValueError("qualification signer registry snapshot is expired") + return self + + +class VerifiedQualificationAdmission(ClosedSignedModel): + """Validated production authority returned only after all exact checks pass.""" + + admission_artifact_sha256: str = Field(pattern=_SHA256_RE) + evidence_identity_sha256: str = Field(pattern=_SHA256_RE) + registry_sha256: str = Field(pattern=_SHA256_RE) + registry_revision: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + registry_expires_at: str + issuer_key_id: str = Field(pattern=_KEY_ID_RE) + admission_id: str + runtime_validation_id: str + + @field_validator("admission_id", "runtime_validation_id") + @classmethod + def _canonical_uuid(cls, value: str, info: Any) -> str: + return _uuid(value, field=info.field_name) + + +_EXPECTED_PAYLOAD_FIELDS: Final[tuple[str, ...]] = ( + "tenant_id", + "workflow_id", + "workflow_version_id", + "bundle_version_id", + "runtime_validation_id", + "bundle_artifact_sha256", + "bundle_content_digest", + "environment_digest", + "governed_authorization_template_sha256", + "application_contract_sha256", + "substrate_contract_sha256", + "environment_contract_sha256", + "runtime_environment_sha256", + "runtime_contract_sha256", + "input_policy_sha256", + "action_policy_sha256", + "network_policy_sha256", + "identity_contract_sha256", + "effect_contract_sha256", + "operator_contract_sha256", +) + + +def load_qualification_signer_registry( + value: str | bytes | Mapping[str, Any], +) -> QualificationSignerRegistry: + """Load one closed v2 registry from a trusted local or control-plane source.""" + + try: + raw = json.loads(value) if isinstance(value, (str, bytes)) else dict(value) + return QualificationSignerRegistry.model_validate(raw) + except (json.JSONDecodeError, TypeError, ValueError) as exc: + raise QualificationAdmissionError( + "qualification signer registry is invalid" + ) from exc + + +def sign_qualification_admission( + payload: QualificationAdmissionPayload, + private_key: Ed25519PrivateKey, +) -> QualificationAdmissionEnvelope: + """Sign one reviewed v2 production qualification decision.""" + + signature = private_key.sign(SIGNATURE_DOMAIN + payload.canonical_bytes()) + return QualificationAdmissionEnvelope( + payload=payload, + signature=b64encode(signature).decode("ascii"), + ) + + +def _verify_registry( + registry: QualificationSignerRegistry, + *, + now: datetime, +) -> None: + generated = _parse_utc(registry.generated_at, field="registry generated_at") + expires = _parse_utc(registry.expires_at, field="registry expires_at") + if generated > now + timedelta(minutes=5): + raise QualificationAdmissionError( + "qualification signer registry is future-issued" + ) + if now >= expires: + raise QualificationAdmissionError("qualification signer registry has expired") + + +def verify_qualification_admission( + envelope: QualificationAdmissionEnvelope, + *, + registry: QualificationSignerRegistry, + expected: QualificationAdmissionExpected, + revoked_admission_ids: set[str] | frozenset[str] = frozenset(), + now: datetime | None = None, +) -> VerifiedQualificationAdmission: + """Verify v2 production authority against independent live values.""" + + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + _verify_registry(registry, now=current) + payload = envelope.payload + identity = payload.evidence_identity + registry_sha256 = registry.artifact_sha256() + if identity.qualification_signer_registry_sha256 != registry_sha256: + raise QualificationAdmissionError( + "qualification admission signer registry digest does not match" + ) + if identity.qualification_signer_registry_revision != registry.revision: + raise QualificationAdmissionError( + "qualification admission signer registry revision does not match" + ) + signer = next( + (item for item in registry.signers if item.key_id == payload.issuer.key_id), + None, + ) + if signer is None: + raise QualificationAdmissionError("qualification signer is not trusted") + if signer.status != "active": + raise QualificationAdmissionError("qualification signer is revoked") + if payload.issuer.workflow not in signer.allowed_workflows: + raise QualificationAdmissionError( + "qualification issuer workflow is not allowed" + ) + if not any( + payload.issuer.ref.startswith(prefix) for prefix in signer.allowed_ref_prefixes + ): + raise QualificationAdmissionError("qualification issuer ref is not allowed") + try: + public_key = Ed25519PublicKey.from_public_bytes( + b64decode(signer.public_key, validate=True) + ) + public_key.verify( + b64decode(envelope.signature, validate=True), + SIGNATURE_DOMAIN + payload.canonical_bytes(), + ) + except (InvalidSignature, ValueError) as exc: + raise QualificationAdmissionError( + "qualification admission signature is invalid" + ) from exc + + issued = _parse_utc(payload.issued_at, field="issued_at") + not_before = _parse_utc(payload.not_before, field="not_before") + expires = _parse_utc(payload.expires_at, field="expires_at") + if issued > current + timedelta(minutes=5): + raise QualificationAdmissionError("qualification admission is future-issued") + if current < not_before: + raise QualificationAdmissionError("qualification admission is not active") + if current >= expires: + raise QualificationAdmissionError("qualification admission has expired") + if payload.admission_id in revoked_admission_ids: + raise QualificationAdmissionError("qualification admission is revoked") + + for field in _EXPECTED_PAYLOAD_FIELDS: + if getattr(payload, field) != getattr(expected, field): + raise QualificationAdmissionError( + f"qualification admission {field} does not match the live run" + ) + if identity.runtime_build_identity != expected.runtime_build_identity: + raise QualificationAdmissionError( + "qualification admission runtime build does not match the live run" + ) + if identity.evidence_runner_signer_sha256 != expected.evidence_runner_signer_sha256: + raise QualificationAdmissionError( + "qualification admission evidence signer does not match the live run" + ) + if identity.deployment_manifest_sha256 != expected.deployment_manifest_sha256: + raise QualificationAdmissionError( + "qualification admission deployment manifest does not match the live run" + ) + if identity.environment_digest != expected.environment_digest: + raise QualificationAdmissionError( + "qualification admission environment does not match the live run" + ) + return VerifiedQualificationAdmission( + admission_artifact_sha256=envelope.artifact_sha256(), + evidence_identity_sha256=identity.artifact_sha256(), + registry_sha256=registry_sha256, + registry_revision=registry.revision, + registry_expires_at=registry.expires_at, + issuer_key_id=payload.issuer.key_id, + admission_id=payload.admission_id, + runtime_validation_id=payload.runtime_validation_id, + ) + + +def verify_qualification_admission_for_actuation( + envelope: QualificationAdmissionEnvelope, + *, + registry: QualificationSignerRegistry, + expected: QualificationAdmissionExpected, + permit_trust_snapshot: QualificationPermitTrustSnapshot, + revoked_admission_ids: set[str] | frozenset[str] = frozenset(), + now: datetime | None = None, +) -> VerifiedQualificationAdmission: + """Reverify authority and its current registry immediately before action.""" + + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + verified = verify_qualification_admission( + envelope, + registry=registry, + expected=expected, + revoked_admission_ids=revoked_admission_ids, + now=current, + ) + checked = _parse_utc( + permit_trust_snapshot.qualification_signer_registry_checked_at, + field="qualification signer registry checked_at", + ) + snapshot_expires = _parse_utc( + permit_trust_snapshot.qualification_signer_registry_expires_at, + field="qualification signer registry expires_at", + ) + if checked > current + timedelta(seconds=5): + raise QualificationAdmissionError( + "qualification signer registry snapshot is future-issued" + ) + if current - checked > MAX_PERMIT_SNAPSHOT_AGE: + raise QualificationAdmissionError( + "qualification signer registry snapshot is stale" + ) + if current >= snapshot_expires: + raise QualificationAdmissionError( + "qualification signer registry snapshot has expired" + ) + expected_snapshot = ( + verified.registry_sha256, + verified.registry_revision, + verified.registry_expires_at, + ) + actual_snapshot = ( + permit_trust_snapshot.qualification_signer_registry_sha256, + permit_trust_snapshot.qualification_signer_registry_revision, + permit_trust_snapshot.qualification_signer_registry_expires_at, + ) + if actual_snapshot != expected_snapshot: + raise QualificationAdmissionError( + "qualification signer registry snapshot does not match" + ) + return verified + + +class QualificationSignerRegistryCheckpoint(ClosedSignedModel): + """Durable registry state retained in the authority transaction.""" + + schema_version: Literal["openadapt.qualification-signer-checkpoint/v1"] = ( + "openadapt.qualification-signer-checkpoint/v1" + ) + revision: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + artifact_sha256: str = Field(pattern=_SHA256_RE) + generated_at: str + + @field_validator("generated_at") + @classmethod + def _generated_at(cls, value: str) -> str: + _parse_utc(value, field="registry checkpoint generated_at") + return value + + +def _registry_checkpoint( + registry: QualificationSignerRegistry, +) -> QualificationSignerRegistryCheckpoint: + return QualificationSignerRegistryCheckpoint( + revision=registry.revision, + artifact_sha256=registry.artifact_sha256(), + generated_at=registry.generated_at, + ) + + +def initialize_qualification_signer_registry_checkpoint( + registry: QualificationSignerRegistry, + *, + now: datetime | None = None, +) -> QualificationSignerRegistryCheckpoint: + """Provision the first durable checkpoint for one signer trust domain. + + This is a provisioning-only operation. A runtime never calls it: a runtime + that finds no durable checkpoint must halt (see + :func:`validate_qualification_signer_registry_transition`). The caller + must persist the returned checkpoint before any admission or permit can be + granted under this registry. + """ + + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + _verify_registry(registry, now=current) + return _registry_checkpoint(registry) + + +def validate_qualification_signer_registry_transition( + registry: QualificationSignerRegistry, + *, + prior_checkpoint: QualificationSignerRegistryCheckpoint, + now: datetime | None = None, +) -> QualificationSignerRegistryCheckpoint: + """Validate one registry against the durable checkpoint and return the next. + + The caller must lock and persist the returned checkpoint in the same + transaction that retains the admission or action authority. One checkpoint + exists per production signer trust domain, not per run or tenant. An + in-memory cache cannot provide rollback protection across a restart, so + ``prior_checkpoint`` is required; a missing durable checkpoint halts. + + Rules: + + * a lower revision is a rollback and is refused; + * the same revision must present the complete same checkpoint (revision, + artifact digest, and generation time) or it is equivocation; + * a higher revision must not carry an earlier generation time; + * an expired or future-dated registry never advances the checkpoint. + """ + + if not isinstance(prior_checkpoint, QualificationSignerRegistryCheckpoint): + raise QualificationAdmissionError( + "qualification signer registry checkpoint is absent; the runtime " + "cannot admit without its durable trust checkpoint" + ) + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + _verify_registry(registry, now=current) + checkpoint = _registry_checkpoint(registry) + if checkpoint.revision < prior_checkpoint.revision: + raise QualificationAdmissionError( + "qualification signer registry revision rolled back" + ) + if checkpoint.revision == prior_checkpoint.revision: + if checkpoint != prior_checkpoint: + raise QualificationAdmissionError( + "qualification signer registry revision changed content" + ) + return checkpoint + if _parse_utc( + checkpoint.generated_at, field="registry checkpoint generated_at" + ) < _parse_utc( + prior_checkpoint.generated_at, + field="prior registry checkpoint generated_at", + ): + raise QualificationAdmissionError( + "qualification signer registry generation time rolled back" + ) + return checkpoint + + +def load_protected_qualification_signer_registry( + path: str | Path, + *, + prior_checkpoint: QualificationSignerRegistryCheckpoint, + now: datetime | None = None, +) -> tuple[QualificationSignerRegistry, QualificationSignerRegistryCheckpoint]: + """Load a protected file and return the registry plus its next checkpoint. + + The caller persists the returned checkpoint before it uses the registry. + """ + + registry = _load_protected_registry_file(path) + checkpoint = validate_qualification_signer_registry_transition( + registry, + prior_checkpoint=prior_checkpoint, + now=now, + ) + return registry, checkpoint + + +def _protected_owner_and_mode(status: os.stat_result, *, label: str) -> None: + if os.name == "nt": + # POSIX mode bits and the owner fallback do not establish a Windows + # ACL. Fail closed instead of claiming a protection this loader cannot + # verify; a Windows runner receives its registry from the control plane. + raise QualificationAdmissionError( + f"{label} protection cannot be verified on this platform" + ) + if status.st_mode & 0o077: + raise QualificationAdmissionError(f"{label} permissions are unsafe") + if status.st_uid not in {0, os.geteuid()}: + raise QualificationAdmissionError(f"{label} owner is unsafe") + + +def _read_protected_file(path: str | Path, *, label: str, limit: int) -> bytes: + resolved = Path(path) + try: + before = resolved.lstat() + except OSError as exc: + raise QualificationAdmissionError(f"{label} is unavailable") from exc + if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode): + raise QualificationAdmissionError(f"{label} path is not a regular file") + _protected_owner_and_mode(before, label=label) + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) + try: + descriptor = os.open(resolved, flags) + try: + after = os.fstat(descriptor) + if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): + raise QualificationAdmissionError(f"{label} changed while loading") + if not stat.S_ISREG(after.st_mode): + raise QualificationAdmissionError(f"{label} path is not a regular file") + # Recheck from the open descriptor: the pre-open lstat can race. + _protected_owner_and_mode(after, label=label) + if after.st_size > limit: + raise QualificationAdmissionError(f"{label} is too large") + value = os.read(descriptor, after.st_size + 1) + if len(value) != after.st_size: + raise QualificationAdmissionError(f"{label} changed while loading") + finally: + os.close(descriptor) + except QualificationAdmissionError: + raise + except OSError as exc: + raise QualificationAdmissionError(f"{label} could not be read safely") from exc + return value + + +def _load_protected_registry_file(path: str | Path) -> QualificationSignerRegistry: + value = _read_protected_file( + path, label="qualification signer registry file", limit=1_000_000 + ) + return load_qualification_signer_registry(value) + + +def read_qualification_signer_checkpoint_file( + path: str | Path, +) -> QualificationSignerRegistryCheckpoint: + """Read the durable local checkpoint; a missing or unsafe file halts.""" + + value = _read_protected_file( + path, label="qualification signer checkpoint file", limit=65_536 + ) + try: + return QualificationSignerRegistryCheckpoint.model_validate_json(value) + except ValueError as exc: + raise QualificationAdmissionError( + "qualification signer checkpoint file is invalid" + ) from exc + + +def _write_private_file(path: Path, payload: bytes, *, exclusive: bool) -> None: + if os.name == "nt": + raise QualificationAdmissionError( + "qualification signer checkpoint file protection cannot be " + "verified on this platform" + ) + if exclusive: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags, 0o600) + except FileExistsError as exc: + raise QualificationAdmissionError( + "qualification signer checkpoint already exists" + ) from exc + except OSError as exc: + raise QualificationAdmissionError( + "qualification signer checkpoint could not be created" + ) from exc + try: + os.write(descriptor, payload) + os.fsync(descriptor) + finally: + os.close(descriptor) + return + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(temporary, flags, 0o600) + except OSError as exc: + raise QualificationAdmissionError( + "qualification signer checkpoint could not be written" + ) from exc + try: + try: + os.write(descriptor, payload) + os.fsync(descriptor) + finally: + os.close(descriptor) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + except OSError as exc: + try: + temporary.unlink() + except OSError: + pass + raise QualificationAdmissionError( + "qualification signer checkpoint could not be written" + ) from exc + + +def initialize_local_qualification_signer_checkpoint( + registry_path: str | Path, + checkpoint_path: str | Path, + *, + now: datetime | None = None, +) -> QualificationSignerRegistryCheckpoint: + """Provision the durable local checkpoint once; refuse if it exists.""" + + registry = _load_protected_registry_file(registry_path) + checkpoint = initialize_qualification_signer_registry_checkpoint(registry, now=now) + _write_private_file( + Path(checkpoint_path), canonical_json(checkpoint), exclusive=True + ) + return checkpoint + + +def load_local_qualification_signer_registry( + registry_path: str | Path, + checkpoint_path: str | Path, + *, + now: datetime | None = None, +) -> tuple[QualificationSignerRegistry, QualificationSignerRegistryCheckpoint]: + """Load the local registry against its durable checkpoint and advance it. + + A missing checkpoint halts. The next checkpoint is persisted before the + registry is returned, so a later rollback of the registry file cannot + authorize an action. + """ + + prior = read_qualification_signer_checkpoint_file(checkpoint_path) + registry, checkpoint = load_protected_qualification_signer_registry( + registry_path, prior_checkpoint=prior, now=now + ) + if checkpoint != prior: + _write_private_file( + Path(checkpoint_path), canonical_json(checkpoint), exclusive=False + ) + return registry, checkpoint diff --git a/openadapt_flow/qualification_campaign_permit.py b/openadapt_flow/qualification_campaign_permit.py new file mode 100644 index 00000000..48d566a6 --- /dev/null +++ b/openadapt_flow/qualification_campaign_permit.py @@ -0,0 +1,283 @@ +"""Signed non-production authority for one qualification campaign trial. + +This contract is intentionally disjoint from production qualification +admission. A qualification campaign permit authorizes only one exact trial in +one exact non-production environment. It cannot be converted into production +authority and cannot represent production success. +""" + +from __future__ import annotations + +import hashlib +import re +from base64 import b64decode, b64encode +from datetime import datetime, timedelta, timezone +from typing import Any, Final, Literal + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from pydantic import Field, StrictInt, field_validator, model_validator + +from openadapt_flow.qualification_admission_v2 import ( + JS_MAX_SAFE_INTEGER, + AdmittedRuntimeBuildIdentity, + ClosedSignedModel, + QualificationIssuer, + QualificationSignerRegistry, + canonical_json, +) + +SCHEMA: Final[Literal["openadapt.qualification-campaign-permit/v1"]] = ( + "openadapt.qualification-campaign-permit/v1" +) +SIGNATURE_DOMAIN: Final[bytes] = b"openadapt-qualification-campaign-permit-v1\0" +POLICY_DOMAIN: Final[bytes] = b"openadapt-qualification-campaign-execution-policy-v1\0" +_SHA256_RE = re.compile(r"^[a-f0-9]{64}$") +_UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,199}$") +_UTC_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") +_KEY_ID_RE = re.compile(r"^qa-ed25519-[a-f0-9]{16}$") +MAX_CAMPAIGN_PERMIT_LIFETIME: Final[timedelta] = timedelta(hours=24) + +QUALIFICATION_CAMPAIGN_EXECUTION_POLICY: Final[dict[str, Any]] = { + "schema_version": "openadapt.qualification-campaign-execution-policy/v1", + "permit_schema": SCHEMA, + "signature_domain": "openadapt-qualification-campaign-permit-v1\\0", + "issuer_workflow": ( + "OpenAdaptAI/openadapt-internal/.github/workflows/" + "production-qualification-admission.yml" + ), + "issuer_ref_prefix": "refs/heads/main@", + "maximum_lifetime_seconds": 24 * 60 * 60, + "minimum_trials_per_condition": 3, + "production_data_prohibited": True, + "production_authority_prohibited": True, + "production_success_prohibited": True, +} +QUALIFICATION_CAMPAIGN_EXECUTION_POLICY_SHA256: Final[str] = hashlib.sha256( + POLICY_DOMAIN + canonical_json(QUALIFICATION_CAMPAIGN_EXECUTION_POLICY) +).hexdigest() + + +class QualificationCampaignPermitError(ValueError): + """The qualification-only trial authority is invalid or stale.""" + + +def _parse_utc(value: str, *, field: str) -> datetime: + if not isinstance(value, str) or _UTC_RE.fullmatch(value) is None: + raise ValueError(f"{field} must be a canonical UTC timestamp") + return datetime.fromisoformat(value[:-1] + "+00:00").astimezone(timezone.utc) + + +class QualificationCampaignTrialBinding(ClosedSignedModel): + campaign_id: str = Field(pattern=_UUID_RE) + campaign_contract_sha256: str = Field(pattern=_SHA256_RE) + trial_id: str = Field(pattern=_UUID_RE) + qualification_run_id: str = Field(pattern=_UUID_RE) + task: str = Field(pattern=_ID_RE) + condition: str = Field(pattern=_ID_RE) + trial_ordinal: StrictInt = Field(ge=1, le=10_000) + required_trials: StrictInt = Field(ge=3, le=10_000) + input_digest: str = Field(pattern=_SHA256_RE) + oracle_contract_sha256: str = Field(pattern=_SHA256_RE) + evidence_sink_sha256: str = Field(pattern=_SHA256_RE) + + @model_validator(mode="after") + def _valid_trial(self) -> "QualificationCampaignTrialBinding": + if self.trial_ordinal > self.required_trials: + raise ValueError("qualification trial ordinal exceeds its plan") + return self + + +class QualificationCampaignPermitPayload(ClosedSignedModel): + schema_version: Literal["openadapt.qualification-campaign-permit/v1"] = SCHEMA + execution_purpose: Literal["qualification_campaign"] = "qualification_campaign" + environment_class: Literal["non_production"] = "non_production" + production_data_prohibited: Literal[True] = True + production_authority_prohibited: Literal[True] = True + production_success_prohibited: Literal[True] = True + permit_id: str = Field(pattern=_UUID_RE) + tenant_id: str = Field(pattern=_UUID_RE) + workflow_id: str = Field(pattern=_UUID_RE) + workflow_version_id: str = Field(pattern=_UUID_RE) + bundle_version_id: str = Field(pattern=_UUID_RE) + bundle_artifact_sha256: str = Field(pattern=_SHA256_RE) + bundle_content_digest: str = Field(pattern=_SHA256_RE) + environment_digest: str = Field(pattern=_SHA256_RE) + environment_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_environment_sha256: str = Field(pattern=_SHA256_RE) + runtime_build_identity: AdmittedRuntimeBuildIdentity + runtime_build_identity_sha256: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_sha256: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_revision: StrictInt = Field( + ge=1, le=JS_MAX_SAFE_INTEGER + ) + trial: QualificationCampaignTrialBinding + policy_sha256: str = Field(pattern=_SHA256_RE) + issuer: QualificationIssuer + issued_at: str + not_before: str + expires_at: str + + @model_validator(mode="after") + def _closed_nonproduction_authority(self) -> "QualificationCampaignPermitPayload": + if self.workflow_version_id != self.bundle_version_id: + raise ValueError("qualification workflow and bundle versions must match") + if ( + self.runtime_build_identity_sha256 + != self.runtime_build_identity.artifact_sha256() + ): + raise ValueError("qualification runtime build digest is invalid") + if self.policy_sha256 != QUALIFICATION_CAMPAIGN_EXECUTION_POLICY_SHA256: + raise ValueError("qualification campaign execution policy is invalid") + if ( + self.issuer.workflow + != QUALIFICATION_CAMPAIGN_EXECUTION_POLICY["issuer_workflow"] + ): + raise ValueError("qualification campaign issuer workflow is invalid") + expected_prefix = QUALIFICATION_CAMPAIGN_EXECUTION_POLICY["issuer_ref_prefix"] + if ( + re.fullmatch(re.escape(expected_prefix) + r"[a-f0-9]{40}", self.issuer.ref) + is None + ): + raise ValueError("qualification campaign issuer ref is invalid") + issued = _parse_utc(self.issued_at, field="campaign permit issued_at") + not_before = _parse_utc(self.not_before, field="campaign permit not_before") + expires = _parse_utc(self.expires_at, field="campaign permit expires_at") + if ( + not issued - timedelta(minutes=5) + <= not_before + <= issued + timedelta(minutes=5) + ): + raise ValueError("qualification campaign permit start is invalid") + if not not_before < expires <= issued + MAX_CAMPAIGN_PERMIT_LIFETIME: + raise ValueError("qualification campaign permit lifetime is invalid") + return self + + def canonical_bytes(self) -> bytes: + return canonical_json(self) + + +class QualificationCampaignPermitEnvelope(ClosedSignedModel): + payload: QualificationCampaignPermitPayload + algorithm: Literal["ed25519"] = "ed25519" + signature: str = Field(min_length=88, max_length=88) + + @field_validator("signature") + @classmethod + def _signature(cls, value: str) -> str: + try: + decoded = b64decode(value, validate=True) + except (TypeError, ValueError) as exc: + raise ValueError("qualification campaign signature is invalid") from exc + if len(decoded) != 64 or b64encode(decoded).decode("ascii") != value: + raise ValueError("qualification campaign signature is invalid") + return value + + def artifact_sha256(self) -> str: + return hashlib.sha256(canonical_json(self)).hexdigest() + + +class QualificationCampaignPermitExpected(ClosedSignedModel): + tenant_id: str = Field(pattern=_UUID_RE) + workflow_id: str = Field(pattern=_UUID_RE) + workflow_version_id: str = Field(pattern=_UUID_RE) + bundle_version_id: str = Field(pattern=_UUID_RE) + bundle_artifact_sha256: str = Field(pattern=_SHA256_RE) + bundle_content_digest: str = Field(pattern=_SHA256_RE) + environment_digest: str = Field(pattern=_SHA256_RE) + environment_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_environment_sha256: str = Field(pattern=_SHA256_RE) + runtime_build_identity: AdmittedRuntimeBuildIdentity + trial: QualificationCampaignTrialBinding + + +_EXPECTED_FIELDS: Final[tuple[str, ...]] = tuple( + QualificationCampaignPermitExpected.model_fields +) + + +def sign_qualification_campaign_permit( + payload: QualificationCampaignPermitPayload, + private_key: Ed25519PrivateKey, +) -> QualificationCampaignPermitEnvelope: + signature = private_key.sign(SIGNATURE_DOMAIN + payload.canonical_bytes()) + return QualificationCampaignPermitEnvelope( + payload=payload, + signature=b64encode(signature).decode("ascii"), + ) + + +def verify_qualification_campaign_permit( + envelope: QualificationCampaignPermitEnvelope, + *, + registry: QualificationSignerRegistry, + expected: QualificationCampaignPermitExpected, + consumed_permit_ids: set[str] | frozenset[str] = frozenset(), + now: datetime | None = None, +) -> str: + """Verify one non-production trial permit against live independent state.""" + + payload = envelope.payload + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + generated = _parse_utc(registry.generated_at, field="registry generated_at") + registry_expires = _parse_utc(registry.expires_at, field="registry expires_at") + if generated > current + timedelta(minutes=5) or current >= registry_expires: + raise QualificationCampaignPermitError( + "qualification signer registry is not current" + ) + if ( + payload.qualification_signer_registry_sha256 != registry.artifact_sha256() + or payload.qualification_signer_registry_revision != registry.revision + ): + raise QualificationCampaignPermitError( + "qualification campaign registry does not match" + ) + signer = next( + (item for item in registry.signers if item.key_id == payload.issuer.key_id), + None, + ) + if signer is None or signer.status != "active": + raise QualificationCampaignPermitError( + "qualification campaign signer is not active" + ) + if payload.issuer.workflow not in signer.allowed_workflows or not any( + payload.issuer.ref.startswith(prefix) for prefix in signer.allowed_ref_prefixes + ): + raise QualificationCampaignPermitError( + "qualification campaign signer scope is invalid" + ) + try: + Ed25519PublicKey.from_public_bytes( + b64decode(signer.public_key, validate=True) + ).verify( + b64decode(envelope.signature, validate=True), + SIGNATURE_DOMAIN + payload.canonical_bytes(), + ) + except (InvalidSignature, ValueError) as exc: + raise QualificationCampaignPermitError( + "qualification campaign signature is invalid" + ) from exc + if current < _parse_utc(payload.not_before, field="campaign permit not_before"): + raise QualificationCampaignPermitError( + "qualification campaign permit is not active" + ) + if current >= _parse_utc(payload.expires_at, field="campaign permit expires_at"): + raise QualificationCampaignPermitError( + "qualification campaign permit has expired" + ) + if payload.permit_id in consumed_permit_ids: + raise QualificationCampaignPermitError( + "qualification campaign permit was already consumed" + ) + for field in _EXPECTED_FIELDS: + if getattr(payload, field) != getattr(expected, field): + raise QualificationCampaignPermitError( + f"qualification campaign {field} does not match live state" + ) + return envelope.artifact_sha256() diff --git a/openadapt_flow/runtime/authorization.py b/openadapt_flow/runtime/authorization.py index ea5eec33..e156dab2 100644 --- a/openadapt_flow/runtime/authorization.py +++ b/openadapt_flow/runtime/authorization.py @@ -527,12 +527,10 @@ def validate_workflow(self, workflow: Workflow) -> str | None: != template.template_sha256 or payload.environment_contract_sha256 != template.qualification_environment_contract_sha256 - or payload.input_policy_sha256 - != template.parameter_contract_sha256 + or payload.input_policy_sha256 != template.parameter_contract_sha256 or payload.action_policy_sha256 != template.qualification_project_contract_sha256 - or payload.identity_contract_sha256 - != template.identity_contract_sha256 + or payload.identity_contract_sha256 != template.identity_contract_sha256 or payload.effect_contract_sha256 != effect_contract_digest ): return ( diff --git a/openadapt_flow/terminal_verification_v2.py b/openadapt_flow/terminal_verification_v2.py new file mode 100644 index 00000000..63064808 --- /dev/null +++ b/openadapt_flow/terminal_verification_v2.py @@ -0,0 +1,1380 @@ +"""Approval-held v2 production terminal proof. + +This module defines the one success-only artifact that a runner can sign after +Flow has built and revalidated a complete VERIFIED report and run receipt. It +is additive and has no production call sites until the production identity +transition and database acceptance transaction are approved. +""" + +from __future__ import annotations + +import hashlib +import re +from base64 import b64decode, b64encode, urlsafe_b64decode, urlsafe_b64encode +from dataclasses import dataclass +from dataclasses import field as dataclass_field +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from typing import Any, Final, Literal + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from pydantic import ( + BaseModel, + Field, + StrictBool, + StrictInt, + field_validator, + model_validator, +) + +from openadapt_flow.ir import PostconditionContractEvidence, RunReport +from openadapt_flow.qualification_admission_v2 import ( + MAX_PERMIT_SNAPSHOT_AGE, + ClosedSignedModel, + canonical_json, +) +from openadapt_flow.receipt import ReceiptError, RunReceipt, build_receipt + +SCHEMA: Final[Literal["openadapt.production-terminal-verification/v2"]] = ( + "openadapt.production-terminal-verification/v2" +) +SIGNATURE_DOMAIN: Final[bytes] = b"openadapt-production-terminal-verification-v2\0" +PERMIT_CHAIN_DOMAIN: Final[bytes] = b"openadapt-managed-delivery-permit-chain-v2\0" +PERMIT_CHAIN_SCHEMA: Final[Literal["openadapt.production-delivery-permit-chain/v2"]] = ( + "openadapt.production-delivery-permit-chain/v2" +) + +_SHA256_RE = re.compile(r"^[a-f0-9]{64}$") +_UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,199}$") +_UTC_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") +_RUNNER_KEY_ID_RE = re.compile(r"^evidence-runner-ed25519-[a-f0-9]{16}$") +JS_MAX_SAFE_INTEGER: Final[int] = 9_007_199_254_740_991 +EXECUTION_OUTCOME_DOMAIN: Final[bytes] = b"openadapt-production-execution-outcome-v2\0" + + +class ProductionTerminalVerificationError(ValueError): + """The production success proof is invalid or does not match live state.""" + + +@dataclass(frozen=True) +class PreparedProductionTerminalEvidence: + """Private exact report bytes and their safe terminal projections.""" + + report_bytes: bytes + report_sha256: str + flow_run_id_sha256: str + bundle_content_digest: str + execution_outcome: "ProductionExecutionOutcome" + run_receipt: "ProductionRunReceipt" + run_receipt_sha256: str + report: RunReport = dataclass_field(repr=False) + + +def _parse_utc(value: str, *, field: str) -> datetime: + if not isinstance(value, str) or _UTC_RE.fullmatch(value) is None: + raise ValueError(f"{field} must be a canonical UTC timestamp") + return datetime.fromisoformat(value[:-1] + "+00:00").astimezone(timezone.utc) + + +def _sha256(value: Any) -> str: + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def evidence_runner_key_id(public_key: bytes) -> str: + if len(public_key) != 32: + raise ValueError("evidence runner public key is invalid") + return f"evidence-runner-ed25519-{hashlib.sha256(public_key).hexdigest()[:16]}" + + +def evidence_runner_signer_sha256(public_key: bytes) -> str: + if len(public_key) != 32: + raise ValueError("evidence runner public key is invalid") + return hashlib.sha256(public_key).hexdigest() + + +class TerminalContractCounts(ClosedSignedModel): + authorization: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + identity: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + postcondition: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + effect: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + + +class ProductionExecutionOutcome(ClosedSignedModel): + """Complete, explicit projection of the current Flow outcome contract.""" + + version: Literal["openadapt.execution-outcome/v1"] = ( + "openadapt.execution-outcome/v1" + ) + outcome: Literal["VERIFIED"] = "VERIFIED" + profile: Literal["standard", "regulated"] + production_eligible: Literal[True] = True + qualification_evidence_only: Literal[False] = False + execution_completed: Literal[True] = True + required_contracts: TerminalContractCounts + passed_contracts: TerminalContractCounts + workflow_contract_sha256: str = Field(pattern=_SHA256_RE) + postcondition_evidence: tuple[PostconditionContractEvidence, ...] = Field( + min_length=1, max_length=10_000 + ) + evidence_classes: tuple[ + Literal[ + "authorization", + "identity", + "postcondition", + "effect_tier_1", + "effect_tier_2", + "effect_tier_3", + "model", + ], + ..., + ] = Field(min_length=4, max_length=7) + model_calls: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + external_network_calls: Literal["none", "observed"] + compensation_actions: Literal[0] = 0 + + @model_validator(mode="after") + def _complete_verified_outcome(self) -> "ProductionExecutionOutcome": + if self.passed_contracts != self.required_contracts: + raise ValueError( + "terminal VERIFIED outcome lacks complete contract coverage" + ) + if self.required_contracts.authorization != 1: + raise ValueError("terminal VERIFIED outcome requires one authorization") + if len(self.postcondition_evidence) != self.required_contracts.postcondition: + raise ValueError("terminal postcondition evidence cardinality is invalid") + if any( + item.verdict != "passed" + or item.workflow_contract_sha256 != self.workflow_contract_sha256 + for item in self.postcondition_evidence + ): + raise ValueError("terminal postcondition evidence is not fully verified") + keys = tuple( + (item.result_index, item.contract_kind, item.contract_index) + for item in self.postcondition_evidence + ) + if len(keys) != len(set(keys)): + raise ValueError("terminal postcondition evidence is duplicated") + if any( + item.result_index > JS_MAX_SAFE_INTEGER + or item.step_index > JS_MAX_SAFE_INTEGER + or item.contract_index > JS_MAX_SAFE_INTEGER + for item in self.postcondition_evidence + ): + raise ValueError("terminal postcondition index exceeds the safe range") + if self.evidence_classes != tuple(sorted(self.evidence_classes)) or len( + self.evidence_classes + ) != len(set(self.evidence_classes)): + raise ValueError("terminal evidence classes must be unique and ordered") + required_classes = {"authorization", "identity", "postcondition"} + if not required_classes.issubset(self.evidence_classes): + raise ValueError( + "terminal VERIFIED outcome lacks required evidence classes" + ) + effect_classes = { + item for item in self.evidence_classes if item.startswith("effect_tier_") + } + if len(effect_classes) != 1: + raise ValueError("terminal VERIFIED outcome requires one effect tier") + if (self.model_calls > 0) != ("model" in self.evidence_classes): + raise ValueError("terminal model evidence does not match its call count") + if self.model_calls > 0 and self.external_network_calls != "observed": + raise ValueError("terminal model calls require observed network evidence") + return self + + def artifact_sha256(self) -> str: + return hashlib.sha256( + EXECUTION_OUTCOME_DOMAIN + canonical_json(self) + ).hexdigest() + + +class ProductionRunReceipt(ClosedSignedModel): + """Cross-language integer projection of every current RunReceipt field.""" + + schema_version: Literal["openadapt.production-run-receipt/v1"] = ( + "openadapt.production-run-receipt/v1" + ) + source_schema_version: Literal["openadapt.run-receipt/v2"] = ( + "openadapt.run-receipt/v2" + ) + outcome: Literal["VERIFIED"] + transaction_outcome: Literal["VERIFIED"] + profile: Literal["standard", "regulated"] + production_eligible: Literal[True] + steps_total: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + steps_ok: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + heals: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + model_calls: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + est_cost_microusd: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + duration_ms: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + rung_histogram: dict[ + Literal[ + "structural", + "template", + "template_global", + "geometry", + "ocr", + "grounder", + "api", + ], + int, + ] + evidence_classes: tuple[ + Literal[ + "authorization", + "identity", + "postcondition", + "effect_tier_1", + "effect_tier_2", + "effect_tier_3", + "model", + ], + ..., + ] = Field(min_length=4, max_length=7) + effect_tier_reached: Literal[ + "independent_system", + "independent_session", + "persisted_state_reacquisition", + ] + authorization_required: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + authorization_confirmed: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + identity_required: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + identity_confirmed: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + postconditions_required: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + postconditions_confirmed: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + effects_required: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + effects_confirmed: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + identity_armed: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + identity_applicable: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + over_halt_count: Literal[0] + substrate: Literal["web", "windows", "macos", "linux", "rdp", "citrix"] + provenance: Literal["production"] + receipt_builder_version: str = Field( + pattern=r"^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:a|b|rc)[0-9]+)?$" + ) + external_network_calls: Literal["none", "observed"] + bundle_digest: str = Field(pattern=_SHA256_RE) + source_receipt_digest: str = Field(pattern=_SHA256_RE) + source_receipt_sha256: str = Field(pattern=_SHA256_RE) + generated_at: str = Field(pattern=r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:00:00Z$") + + @model_validator(mode="after") + def _closed_receipt(self) -> "ProductionRunReceipt": + for label, required, confirmed in ( + ( + "authorization", + self.authorization_required, + self.authorization_confirmed, + ), + ("identity", self.identity_required, self.identity_confirmed), + ( + "postcondition", + self.postconditions_required, + self.postconditions_confirmed, + ), + ("effect", self.effects_required, self.effects_confirmed), + ): + if required != confirmed: + raise ValueError(f"production receipt lacks complete {label} coverage") + if self.authorization_required != 1: + raise ValueError("production receipt requires one authorization") + if self.steps_ok != self.steps_total: + raise ValueError("production receipt requires all steps to succeed") + if self.identity_armed != self.identity_applicable: + raise ValueError("production receipt requires complete identity arming") + if self.evidence_classes != tuple(sorted(self.evidence_classes)) or len( + self.evidence_classes + ) != len(set(self.evidence_classes)): + raise ValueError("production receipt evidence classes are invalid") + if len(self.rung_histogram) > 7 or any( + type(value) is not int or value < 0 or value > JS_MAX_SAFE_INTEGER + for value in self.rung_histogram.values() + ): + raise ValueError("production receipt rung counts are invalid") + return self + + +def project_production_run_receipt(receipt: RunReceipt) -> ProductionRunReceipt: + try: + micros = Decimal(str(receipt.est_cost_usd)) * Decimal(1_000_000) + except InvalidOperation as exc: + raise ProductionTerminalVerificationError( + "run receipt cost is not an exact integer microusd value" + ) from exc + if micros != micros.to_integral_value(): + raise ProductionTerminalVerificationError( + "run receipt cost is not an exact integer microusd value" + ) + source = receipt.model_dump(mode="json") + # ``model_validate`` narrows the wider RunReceipt literals (for example a + # non-production provenance) through the closed production validators + # instead of a static cast; a value outside the production subset raises. + return ProductionRunReceipt.model_validate( + { + "outcome": receipt.outcome, + "transaction_outcome": receipt.transaction_outcome, + "profile": receipt.profile, + "production_eligible": receipt.production_eligible, + "steps_total": receipt.steps_total, + "steps_ok": receipt.steps_ok, + "heals": receipt.heals, + "model_calls": receipt.model_calls, + "est_cost_microusd": int(micros), + "duration_ms": receipt.duration_ms, + "rung_histogram": receipt.rung_histogram, + "evidence_classes": tuple(receipt.evidence_classes), + "effect_tier_reached": receipt.effect_tier_reached, + "authorization_required": receipt.authorization_required, + "authorization_confirmed": receipt.authorization_confirmed, + "identity_required": receipt.identity_required, + "identity_confirmed": receipt.identity_confirmed, + "postconditions_required": receipt.postconditions_required, + "postconditions_confirmed": receipt.postconditions_confirmed, + "effects_required": receipt.effects_required, + "effects_confirmed": receipt.effects_confirmed, + "identity_armed": receipt.identity_armed, + "identity_applicable": receipt.identity_applicable, + "over_halt_count": receipt.over_halt_count, + "substrate": receipt.substrate, + "provenance": receipt.provenance, + "receipt_builder_version": receipt.receipt_builder_version, + "external_network_calls": receipt.external_network_calls, + "bundle_digest": receipt.bundle_digest, + "source_receipt_digest": receipt.receipt_digest, + "source_receipt_sha256": _sha256(source), + "generated_at": receipt.generated_at, + } + ) + + +def prepare_production_terminal_evidence( + report: RunReport, +) -> PreparedProductionTerminalEvidence: + """Revalidate the exact report and build the only terminal-safe projections. + + The returned report bytes are the bytes that the runner must store as the + immutable report object. The production proof later binds the storage + version and the SHA-256 of these exact bytes. + """ + + try: + validated = RunReport.model_validate_json(report.model_dump_json()) + except (AttributeError, TypeError, ValueError) as exc: + raise ProductionTerminalVerificationError( + "terminal report failed typed JSON revalidation" + ) from exc + envelope = validated.outcome_envelope + if ( + envelope is None + or validated.run_id_sha256 is None + or validated.bundle_content_digest is None + ): + raise ProductionTerminalVerificationError( + "terminal report lacks its run or bundle binding" + ) + try: + receipt = project_production_run_receipt(build_receipt(validated)) + outcome = ProductionExecutionOutcome.model_validate( + { + "version": envelope.version, + "outcome": envelope.outcome, + "profile": envelope.profile, + "production_eligible": envelope.production_eligible, + "qualification_evidence_only": envelope.qualification_evidence_only, + "execution_completed": envelope.execution_completed, + "required_contracts": envelope.required_contracts.model_dump( + mode="json" + ), + "passed_contracts": envelope.passed_contracts.model_dump(mode="json"), + "workflow_contract_sha256": envelope.workflow_contract_sha256, + "postcondition_evidence": [ + item.model_dump(mode="json") + for item in envelope.postcondition_evidence + ], + "evidence_classes": envelope.evidence_classes, + "model_calls": envelope.model_calls, + "external_network_calls": envelope.external_network_calls, + "compensation_actions": envelope.compensation_actions, + } + ) + except (ReceiptError, ValueError) as exc: + raise ProductionTerminalVerificationError( + "terminal report is not a complete production VERIFIED outcome" + ) from exc + report_bytes = canonical_json(validated.model_dump(mode="json")) + report_sha256 = hashlib.sha256(report_bytes).hexdigest() + receipt_sha256 = _sha256(receipt.model_dump(mode="json")) + return PreparedProductionTerminalEvidence( + report_bytes=report_bytes, + report_sha256=report_sha256, + flow_run_id_sha256=validated.run_id_sha256, + bundle_content_digest=validated.bundle_content_digest, + execution_outcome=outcome, + run_receipt=receipt, + run_receipt_sha256=receipt_sha256, + report=validated, + ) + + +def _evidence_manifest_sha256(domain: bytes, value: BaseModel) -> str: + payload = value.model_dump(mode="json") + payload.pop("manifest_sha256", None) + return hashlib.sha256(domain + canonical_json(payload)).hexdigest() + + +class ProductionPolicyEvidenceManifest(ClosedSignedModel): + schema_version: Literal["openadapt.production-policy-evidence/v1"] = ( + "openadapt.production-policy-evidence/v1" + ) + admission_policy_sha256: str = Field(pattern=_SHA256_RE) + governed_policy_contract_sha256: str = Field(pattern=_SHA256_RE) + governed_runtime_inputs_digest: str = Field(pattern=_SHA256_RE) + environment_digest: str = Field(pattern=_SHA256_RE) + environment_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_environment_sha256: str = Field(pattern=_SHA256_RE) + identity_contract_sha256: str = Field(pattern=_SHA256_RE) + effect_contract_sha256: str = Field(pattern=_SHA256_RE) + minimum_effect_tier: Literal[1, 2, 3] + manifest_sha256: str = Field(pattern=_SHA256_RE) + + @model_validator(mode="after") + def _digest(self) -> "ProductionPolicyEvidenceManifest": + expected = _evidence_manifest_sha256( + b"openadapt-production-policy-evidence-v1\0", self + ) + if self.manifest_sha256 != expected: + raise ValueError("production policy evidence digest is invalid") + return self + + +class ProductionAuthorizationEvidenceManifest(ClosedSignedModel): + schema_version: Literal["openadapt.production-authorization-evidence/v1"] = ( + "openadapt.production-authorization-evidence/v1" + ) + governed_authorization_id_sha256: str = Field(pattern=_SHA256_RE) + admission_id: str = Field(pattern=_UUID_RE) + admission_artifact_sha256: str = Field(pattern=_SHA256_RE) + execution_authority_id: str = Field(pattern=_ID_RE) + execution_authority_sha256: str = Field(pattern=_SHA256_RE) + permit_chain_sha256: str = Field(pattern=_SHA256_RE) + manifest_sha256: str = Field(pattern=_SHA256_RE) + + @model_validator(mode="after") + def _digest(self) -> "ProductionAuthorizationEvidenceManifest": + expected = _evidence_manifest_sha256( + b"openadapt-production-authorization-evidence-v1\0", self + ) + if self.manifest_sha256 != expected: + raise ValueError("production authorization evidence digest is invalid") + return self + + +class ProductionIdentitySignal(ClosedSignedModel): + signal: Literal[ + "subject_name", + "record_id", + "secondary_identifier", + "application", + "session", + "workflow_state", + ] + source: Literal[ + "structured", + "identifier_region", + "captured_context", + "application", + "session", + "workflow_state", + "api_parameter", + ] + evidence_class: Literal[ + "application_structured_text", + "recorded_and_live_region", + "captured_context_ocr", + "application_identity", + "session_identity", + "workflow_state_identity", + "api_request_effect_binding", + ] + verdict: Literal["verified", "conflict", "unverifiable"] + match: Literal["exact", "normalized"] + + +class ProductionIdentityResult(ClosedSignedModel): + result_index: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + status: Literal["verified"] + mode: Literal["context", "param", "structured", "pixel", "vlm", "signal_quorum"] + signals: tuple[ProductionIdentitySignal, ...] = Field(max_length=32) + + @model_validator(mode="after") + def _ordered_signals(self) -> "ProductionIdentityResult": + keys = tuple( + (item.signal, item.source, item.evidence_class, item.verdict, item.match) + for item in self.signals + ) + if keys != tuple(sorted(keys)) or len(keys) != len(set(keys)): + raise ValueError("production identity signals must be unique and ordered") + return self + + +class ProductionIdentityEvidenceManifest(ClosedSignedModel): + schema_version: Literal["openadapt.production-identity-evidence/v1"] = ( + "openadapt.production-identity-evidence/v1" + ) + identity_contract_sha256: str = Field(pattern=_SHA256_RE) + workflow_contract_sha256: str = Field(pattern=_SHA256_RE) + required: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + confirmed: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + results: tuple[ProductionIdentityResult, ...] = Field( + min_length=1, max_length=10_000 + ) + manifest_sha256: str = Field(pattern=_SHA256_RE) + + @model_validator(mode="after") + def _digest(self) -> "ProductionIdentityEvidenceManifest": + if self.required != self.confirmed or len(self.results) != self.required: + raise ValueError("production identity evidence coverage is incomplete") + indices = tuple(item.result_index for item in self.results) + if indices != tuple(sorted(indices)) or len(indices) != len(set(indices)): + raise ValueError("production identity evidence results are invalid") + expected = _evidence_manifest_sha256( + b"openadapt-production-identity-evidence-v1\0", self + ) + if self.manifest_sha256 != expected: + raise ValueError("production identity evidence digest is invalid") + return self + + +class ProductionPostconditionEvidenceManifest(ClosedSignedModel): + schema_version: Literal["openadapt.production-postcondition-evidence/v1"] = ( + "openadapt.production-postcondition-evidence/v1" + ) + workflow_contract_sha256: str = Field(pattern=_SHA256_RE) + required: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + confirmed: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + records: tuple[PostconditionContractEvidence, ...] = Field( + min_length=1, max_length=10_000 + ) + manifest_sha256: str = Field(pattern=_SHA256_RE) + + @model_validator(mode="after") + def _digest(self) -> "ProductionPostconditionEvidenceManifest": + if self.required != self.confirmed or len(self.records) != self.required: + raise ValueError("production postcondition evidence coverage is incomplete") + if any( + item.verdict != "passed" + or item.workflow_contract_sha256 != self.workflow_contract_sha256 + for item in self.records + ): + raise ValueError("production postcondition evidence is invalid") + expected = _evidence_manifest_sha256( + b"openadapt-production-postcondition-evidence-v1\0", self + ) + if self.manifest_sha256 != expected: + raise ValueError("production postcondition evidence digest is invalid") + return self + + +class ProductionEffectEvidence(ClosedSignedModel): + result_index: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + effect_contract_hash: str = Field(pattern=r"^sha256:[a-f0-9]{64}$") + verifier_identity: str | None = Field( + default=None, pattern=r"^sha256:[a-f0-9]{64}$" + ) + verification_tier: Literal[1, 2, 3] + final_verdict: Literal["confirmed"] + observed_effect: Literal["present"] + reconciliation_completed: StrictBool + reconciliation_actions: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + + +class ProductionEffectEvidenceManifest(ClosedSignedModel): + schema_version: Literal["openadapt.production-effect-evidence/v1"] = ( + "openadapt.production-effect-evidence/v1" + ) + effect_contract_sha256: str = Field(pattern=_SHA256_RE) + workflow_contract_sha256: str = Field(pattern=_SHA256_RE) + required: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + confirmed: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + records: tuple[ProductionEffectEvidence, ...] = Field( + min_length=1, max_length=10_000 + ) + manifest_sha256: str = Field(pattern=_SHA256_RE) + + @model_validator(mode="after") + def _digest(self) -> "ProductionEffectEvidenceManifest": + if self.required != self.confirmed or len(self.records) != self.required: + raise ValueError("production effect evidence coverage is incomplete") + keys = tuple( + (item.result_index, item.effect_contract_hash) for item in self.records + ) + if keys != tuple(sorted(keys)) or len(keys) != len(set(keys)): + raise ValueError("production effect evidence records are invalid") + expected = _evidence_manifest_sha256( + b"openadapt-production-effect-evidence-v1\0", self + ) + if self.manifest_sha256 != expected: + raise ValueError("production effect evidence digest is invalid") + return self + + +class ProductionEvidenceManifests(ClosedSignedModel): + policy: ProductionPolicyEvidenceManifest + authorization: ProductionAuthorizationEvidenceManifest + identity: ProductionIdentityEvidenceManifest + postcondition: ProductionPostconditionEvidenceManifest + effect: ProductionEffectEvidenceManifest + + +def build_evidence_manifest(model: type[BaseModel], **values: Any) -> BaseModel: + """Build one domain-separated manifest through its closed typed validator.""" + + draft = model.model_construct(**values, manifest_sha256="0" * 64) + domain = { + ProductionPolicyEvidenceManifest: b"openadapt-production-policy-evidence-v1\0", + ProductionAuthorizationEvidenceManifest: b"openadapt-production-authorization-evidence-v1\0", + ProductionIdentityEvidenceManifest: b"openadapt-production-identity-evidence-v1\0", + ProductionPostconditionEvidenceManifest: b"openadapt-production-postcondition-evidence-v1\0", + ProductionEffectEvidenceManifest: b"openadapt-production-effect-evidence-v1\0", + }.get(model) + if domain is None: + raise TypeError("unsupported production evidence manifest") + digest = _evidence_manifest_sha256(domain, draft) + return model.model_validate({**values, "manifest_sha256": digest}) + + +class ProductionDeliveryPermit(ClosedSignedModel): + execution_authority_id: str = Field(pattern=_ID_RE) + execution_authority_sha256: str = Field(pattern=_SHA256_RE) + permit_id: str = Field(pattern=_ID_RE) + permit_sha256: str = Field(pattern=_SHA256_RE) + run_request_sha256: str = Field(pattern=_SHA256_RE) + action_request_sha256: str = Field(pattern=_SHA256_RE) + admission_artifact_sha256: str = Field(pattern=_SHA256_RE) + evidence_identity_sha256: str = Field(pattern=_SHA256_RE) + environment_digest: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_sha256: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_revision: StrictInt = Field( + ge=1, le=JS_MAX_SAFE_INTEGER + ) + qualification_signer_registry_checked_at: str + qualification_signer_registry_expires_at: str + input_edge_sequence: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + authority_sequence: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + runtime_delivery_sequence: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + issued_at: str + delivered_at: str + + @model_validator(mode="after") + def _valid_permit_time(self) -> "ProductionDeliveryPermit": + checked = _parse_utc( + self.qualification_signer_registry_checked_at, + field="permit registry checked_at", + ) + expires = _parse_utc( + self.qualification_signer_registry_expires_at, + field="permit registry expires_at", + ) + issued = _parse_utc(self.issued_at, field="permit issued_at") + delivered = _parse_utc(self.delivered_at, field="permit delivered_at") + if not checked <= issued <= delivered < expires: + raise ValueError("permit was not issued under a fresh signer registry") + if issued - checked > MAX_PERMIT_SNAPSHOT_AGE: + raise ValueError( + "permit registry check is stale; the issuing authority must " + "recheck the signer registry within 60 seconds of permit issue" + ) + return self + + +class ProductionDeliveryPermitChain(ClosedSignedModel): + schema_version: Literal["openadapt.production-delivery-permit-chain/v2"] = ( + PERMIT_CHAIN_SCHEMA + ) + entries: tuple[ProductionDeliveryPermit, ...] = Field( + min_length=1, max_length=10_000 + ) + permit_chain_sha256: str = Field(pattern=_SHA256_RE) + + @model_validator(mode="after") + def _closed_chain(self) -> "ProductionDeliveryPermitChain": + authority = self.entries[0].execution_authority_id + authority_digest = self.entries[0].execution_authority_sha256 + admission_digest = self.entries[0].admission_artifact_sha256 + evidence_identity_digest = self.entries[0].evidence_identity_sha256 + environment_digest = self.entries[0].environment_digest + registry_digest = self.entries[0].qualification_signer_registry_sha256 + registry_revision = self.entries[0].qualification_signer_registry_revision + registry_expiry = self.entries[0].qualification_signer_registry_expires_at + run_request_digest = self.entries[0].run_request_sha256 + if any( + item.execution_authority_id != authority + or item.execution_authority_sha256 != authority_digest + or item.admission_artifact_sha256 != admission_digest + or item.evidence_identity_sha256 != evidence_identity_digest + or item.environment_digest != environment_digest + or item.qualification_signer_registry_sha256 != registry_digest + or item.qualification_signer_registry_revision != registry_revision + or item.qualification_signer_registry_expires_at != registry_expiry + or item.run_request_sha256 != run_request_digest + for item in self.entries + ): + raise ValueError("delivery permit chain changes its production authority") + input_sequences = tuple(item.input_edge_sequence for item in self.entries) + authority_sequences = tuple(item.authority_sequence for item in self.entries) + delivery_sequences = tuple( + item.runtime_delivery_sequence for item in self.entries + ) + exact = tuple(range(1, len(self.entries) + 1)) + if input_sequences != exact: + raise ValueError( + "delivery input-edge sequence must be exact and contiguous" + ) + if authority_sequences != tuple( + range(authority_sequences[0], authority_sequences[0] + len(self.entries)) + ): + raise ValueError("delivery authority sequence must be contiguous") + if delivery_sequences != tuple( + range(delivery_sequences[0], delivery_sequences[0] + len(self.entries)) + ): + raise ValueError("runtime delivery sequence must be contiguous") + permit_ids = tuple(item.permit_id for item in self.entries) + if len(permit_ids) != len(set(permit_ids)): + raise ValueError("delivery permit chain repeats a permit") + action_requests = tuple(item.action_request_sha256 for item in self.entries) + if len(action_requests) != len(set(action_requests)): + raise ValueError("delivery permit chain repeats an action request") + event_times = tuple( + ( + _parse_utc(item.issued_at, field="permit issued_at"), + _parse_utc(item.delivered_at, field="permit delivered_at"), + ) + for item in self.entries + ) + if any( + previous[1] > current[0] + for previous, current in zip(event_times, event_times[1:]) + ): + raise ValueError("delivery permit chronology is invalid") + expected = hashlib.sha256( + PERMIT_CHAIN_DOMAIN + + canonical_json( + { + "schema_version": self.schema_version, + "entries": [item.model_dump(mode="json") for item in self.entries], + } + ) + ).hexdigest() + if self.permit_chain_sha256 != expected: + raise ValueError("delivery permit chain digest is invalid") + return self + + @classmethod + def build( + cls, entries: tuple[ProductionDeliveryPermit, ...] + ) -> "ProductionDeliveryPermitChain": + payload = { + "schema_version": PERMIT_CHAIN_SCHEMA, + "entries": [item.model_dump(mode="json") for item in entries], + } + digest = hashlib.sha256( + PERMIT_CHAIN_DOMAIN + canonical_json(payload) + ).hexdigest() + return cls(entries=entries, permit_chain_sha256=digest) + + +def build_production_evidence_manifests( + prepared: PreparedProductionTerminalEvidence, + *, + admission_policy_sha256: str, + environment_digest: str, + environment_contract_sha256: str, + runtime_environment_sha256: str, + identity_contract_sha256: str, + effect_contract_sha256: str, + admission_id: str, + admission_artifact_sha256: str, + execution_authority_id: str, + execution_authority_sha256: str, + permit_chain: ProductionDeliveryPermitChain, +) -> ProductionEvidenceManifests: + """Recompute all five class-separated manifests from the retained report.""" + + report = prepared.report + outcome = prepared.execution_outcome + if ( + report.governed_policy_contract_sha256 is None + or report.governed_runtime_inputs_digest is None + or report.governed_minimum_effect_tier not in {1, 2, 3} + or report.governed_authorization_id is None + ): + raise ProductionTerminalVerificationError( + "terminal report lacks retained policy or authorization evidence" + ) + policy = build_evidence_manifest( + ProductionPolicyEvidenceManifest, + admission_policy_sha256=admission_policy_sha256, + governed_policy_contract_sha256=report.governed_policy_contract_sha256, + governed_runtime_inputs_digest=report.governed_runtime_inputs_digest, + environment_digest=environment_digest, + environment_contract_sha256=environment_contract_sha256, + runtime_environment_sha256=runtime_environment_sha256, + identity_contract_sha256=identity_contract_sha256, + effect_contract_sha256=effect_contract_sha256, + minimum_effect_tier=report.governed_minimum_effect_tier, + ) + authorization = build_evidence_manifest( + ProductionAuthorizationEvidenceManifest, + governed_authorization_id_sha256=hashlib.sha256( + report.governed_authorization_id.encode("utf-8") + ).hexdigest(), + admission_id=admission_id, + admission_artifact_sha256=admission_artifact_sha256, + execution_authority_id=execution_authority_id, + execution_authority_sha256=execution_authority_sha256, + permit_chain_sha256=permit_chain.permit_chain_sha256, + ) + identity_results: list[ProductionIdentityResult] = [] + for result_index, result in enumerate(report.results): + if result.skipped or result.exception_handled or result.identity is None: + continue + identity_results.append( + ProductionIdentityResult.model_validate( + { + "result_index": result_index, + "status": result.identity.status, + "mode": result.identity.mode, + "signals": tuple( + sorted( + ( + ProductionIdentitySignal.model_validate( + { + "signal": item.signal, + "source": item.source, + "evidence_class": item.evidence_class, + "verdict": item.verdict, + "match": item.match, + } + ) + for item in result.identity.signal_evidence + ), + key=lambda item: ( + item.signal, + item.source, + item.evidence_class, + item.verdict, + item.match, + ), + ) + ), + } + ) + ) + identity = build_evidence_manifest( + ProductionIdentityEvidenceManifest, + identity_contract_sha256=identity_contract_sha256, + workflow_contract_sha256=outcome.workflow_contract_sha256, + required=outcome.required_contracts.identity, + confirmed=outcome.passed_contracts.identity, + results=tuple(identity_results), + ) + postcondition = build_evidence_manifest( + ProductionPostconditionEvidenceManifest, + workflow_contract_sha256=outcome.workflow_contract_sha256, + required=outcome.required_contracts.postcondition, + confirmed=outcome.passed_contracts.postcondition, + records=outcome.postcondition_evidence, + ) + effect_records: list[ProductionEffectEvidence] = [] + for result_index, result in enumerate(report.results): + if result.skipped or result.exception_handled: + continue + for item in result.effect_evidence: + effect_records.append( + ProductionEffectEvidence.model_validate( + { + "result_index": result_index, + "effect_contract_hash": item.effect_contract_hash, + "verifier_identity": item.verifier_identity, + "verification_tier": item.verification_tier, + "final_verdict": item.final_verdict, + "observed_effect": item.observed_effect, + "reconciliation_completed": item.reconciliation_completed, + "reconciliation_actions": item.reconciliation_actions, + } + ) + ) + effect_records.sort(key=lambda item: (item.result_index, item.effect_contract_hash)) + effect = build_evidence_manifest( + ProductionEffectEvidenceManifest, + effect_contract_sha256=effect_contract_sha256, + workflow_contract_sha256=outcome.workflow_contract_sha256, + required=outcome.required_contracts.effect, + confirmed=outcome.passed_contracts.effect, + records=tuple(effect_records), + ) + return ProductionEvidenceManifests.model_validate( + { + "policy": policy, + "authorization": authorization, + "identity": identity, + "postcondition": postcondition, + "effect": effect, + } + ) + + +class ProductionTerminalVerificationPayload(ClosedSignedModel): + schema_version: Literal["openadapt.production-terminal-verification/v2"] = SCHEMA + terminal_sequence: Literal[1] = 1 + execution_purpose: Literal["production"] = "production" + run_id: str = Field(pattern=_UUID_RE) + flow_run_id_sha256: str = Field(pattern=_SHA256_RE) + tenant_id: str = Field(pattern=_UUID_RE) + workflow_id: str = Field(pattern=_UUID_RE) + workflow_version_id: str = Field(pattern=_UUID_RE) + bundle_version_id: str = Field(pattern=_UUID_RE) + bundle_artifact_sha256: str = Field(pattern=_SHA256_RE) + bundle_content_digest: str = Field(pattern=_SHA256_RE) + environment_digest: str = Field(pattern=_SHA256_RE) + environment_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_environment_sha256: str = Field(pattern=_SHA256_RE) + identity_contract_sha256: str = Field(pattern=_SHA256_RE) + effect_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_validation_id: str = Field(pattern=_UUID_RE) + runtime_substrate: Literal["web", "windows", "macos", "linux", "rdp", "citrix"] + admission_id: str = Field(pattern=_UUID_RE) + admission_artifact_sha256: str = Field(pattern=_SHA256_RE) + admission_policy_sha256: str = Field(pattern=_SHA256_RE) + evidence_identity_sha256: str = Field(pattern=_SHA256_RE) + admitted_runtime_build_sha256: str = Field(pattern=_SHA256_RE) + evidence_runner_signer_sha256: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_sha256: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_revision: StrictInt = Field( + ge=1, le=JS_MAX_SAFE_INTEGER + ) + execution_authority_id: str = Field(pattern=_ID_RE) + execution_authority_sha256: str = Field(pattern=_SHA256_RE) + permit_chain: ProductionDeliveryPermitChain + permit_count: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + final_authority_sequence: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + final_runtime_delivery_sequence: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + workflow_contract_sha256: str = Field(pattern=_SHA256_RE) + execution_outcome: ProductionExecutionOutcome + execution_outcome_sha256: str = Field(pattern=_SHA256_RE) + run_receipt: ProductionRunReceipt + run_receipt_sha256: str = Field(pattern=_SHA256_RE) + run_report_sha256: str = Field(pattern=_SHA256_RE) + run_report_object_version: str = Field(pattern=_ID_RE) + run_report_object_sha256: str = Field(pattern=_SHA256_RE) + evidence_manifests: ProductionEvidenceManifests + verified_at: str + issued_at: str + + @model_validator(mode="after") + def _closed_terminal_success(self) -> "ProductionTerminalVerificationPayload": + if self.admission_id == self.runtime_validation_id: + raise ValueError("terminal admission and runtime identities must differ") + if self.workflow_version_id != self.bundle_version_id: + raise ValueError("terminal workflow and bundle versions must match") + chain = self.permit_chain + final = chain.entries[-1] + if ( + self.execution_authority_id, + self.execution_authority_sha256, + self.admission_artifact_sha256, + ) != ( + final.execution_authority_id, + final.execution_authority_sha256, + final.admission_artifact_sha256, + ): + raise ValueError("terminal authority does not match its permit chain") + if ( + self.evidence_identity_sha256, + self.environment_digest, + self.qualification_signer_registry_sha256, + self.qualification_signer_registry_revision, + ) != ( + final.evidence_identity_sha256, + final.environment_digest, + final.qualification_signer_registry_sha256, + final.qualification_signer_registry_revision, + ): + raise ValueError("terminal qualification state does not match its permits") + if ( + self.permit_count, + self.final_authority_sequence, + self.final_runtime_delivery_sequence, + ) != ( + len(chain.entries), + final.authority_sequence, + final.runtime_delivery_sequence, + ): + raise ValueError("terminal permit counts do not match the permit chain") + receipt = self.run_receipt + outcome = self.execution_outcome + manifests = self.evidence_manifests + if ( + self.workflow_contract_sha256 != outcome.workflow_contract_sha256 + or self.execution_outcome_sha256 != outcome.artifact_sha256() + ): + raise ValueError("terminal execution outcome digest is invalid") + if ( + manifests.policy.admission_policy_sha256 != self.admission_policy_sha256 + or manifests.policy.environment_digest != self.environment_digest + or manifests.policy.environment_contract_sha256 + != self.environment_contract_sha256 + or manifests.policy.runtime_environment_sha256 + != self.runtime_environment_sha256 + or manifests.policy.identity_contract_sha256 + != self.identity_contract_sha256 + or manifests.policy.effect_contract_sha256 != self.effect_contract_sha256 + or manifests.authorization.admission_id != self.admission_id + or manifests.authorization.admission_artifact_sha256 + != self.admission_artifact_sha256 + or manifests.authorization.execution_authority_id + != self.execution_authority_id + or manifests.authorization.execution_authority_sha256 + != self.execution_authority_sha256 + or manifests.authorization.permit_chain_sha256 != chain.permit_chain_sha256 + or manifests.identity.identity_contract_sha256 + != self.identity_contract_sha256 + or manifests.identity.workflow_contract_sha256 + != self.workflow_contract_sha256 + or manifests.postcondition.workflow_contract_sha256 + != self.workflow_contract_sha256 + or manifests.effect.effect_contract_sha256 != self.effect_contract_sha256 + or manifests.effect.workflow_contract_sha256 + != self.workflow_contract_sha256 + or ( + manifests.identity.required, + manifests.postcondition.required, + manifests.effect.required, + ) + != ( + outcome.required_contracts.identity, + outcome.required_contracts.postcondition, + outcome.required_contracts.effect, + ) + ): + raise ValueError("terminal evidence manifests do not match the run") + if receipt.source_receipt_digest == "" or self.run_receipt_sha256 != _sha256( + receipt.model_dump(mode="json") + ): + raise ValueError("terminal run receipt digest is invalid") + if ( + receipt.profile != outcome.profile + or receipt.provenance != "production" + or receipt.substrate != self.runtime_substrate + or receipt.bundle_digest != self.bundle_content_digest + or receipt.authorization_required + != outcome.required_contracts.authorization + or receipt.authorization_confirmed != outcome.passed_contracts.authorization + or receipt.identity_required != outcome.required_contracts.identity + or receipt.identity_confirmed != outcome.passed_contracts.identity + or receipt.postconditions_required + != outcome.required_contracts.postcondition + or receipt.postconditions_confirmed + != outcome.passed_contracts.postcondition + or receipt.effects_required != outcome.required_contracts.effect + or receipt.effects_confirmed != outcome.passed_contracts.effect + or tuple(receipt.evidence_classes) != outcome.evidence_classes + or receipt.model_calls != outcome.model_calls + or receipt.external_network_calls != outcome.external_network_calls + ): + raise ValueError( + "terminal run receipt does not match its execution outcome" + ) + verified = _parse_utc(self.verified_at, field="terminal verified_at") + issued = _parse_utc(self.issued_at, field="terminal issued_at") + if not verified <= issued <= verified + timedelta(minutes=5): + raise ValueError("terminal proof issue time is invalid") + final_delivered = _parse_utc(final.delivered_at, field="permit delivered_at") + registry_expires = _parse_utc( + final.qualification_signer_registry_expires_at, + field="permit registry expires_at", + ) + if not final_delivered <= verified < registry_expires: + raise ValueError("terminal verification is outside permit chronology") + if self.run_report_object_sha256 != self.run_report_sha256: + raise ValueError( + "terminal report object must contain the exact revalidated report bytes" + ) + return self + + def canonical_bytes(self) -> bytes: + return canonical_json(self) + + +class EvidenceRunnerSigner(ClosedSignedModel): + algorithm: Literal["ed25519"] = "ed25519" + key_id: str = Field(pattern=_RUNNER_KEY_ID_RE) + public_key: str + + @field_validator("public_key") + @classmethod + def _canonical_public_key(cls, value: str) -> str: + try: + decoded = b64decode(value, validate=True) + except (TypeError, ValueError) as exc: + raise ValueError("evidence runner public key is invalid") from exc + if len(decoded) != 32 or b64encode(decoded).decode("ascii") != value: + raise ValueError("evidence runner public key is invalid") + return value + + @model_validator(mode="after") + def _key_id_matches(self) -> "EvidenceRunnerSigner": + if ( + evidence_runner_key_id(b64decode(self.public_key, validate=True)) + != self.key_id + ): + raise ValueError("evidence runner key id does not match its public key") + return self + + +class ProductionTerminalVerificationEnvelope(ClosedSignedModel): + payload: ProductionTerminalVerificationPayload + signer: EvidenceRunnerSigner + signature: str = Field(min_length=86, max_length=86) + + @field_validator("signature") + @classmethod + def _canonical_signature(cls, value: str) -> str: + try: + decoded = urlsafe_b64decode(value + "==") + except ValueError as exc: + raise ValueError("terminal signature is invalid") from exc + if ( + len(decoded) != 64 + or urlsafe_b64encode(decoded).decode("ascii").rstrip("=") != value + ): + raise ValueError("terminal signature is invalid") + return value + + @model_validator(mode="after") + def _signer_matches_payload(self) -> "ProductionTerminalVerificationEnvelope": + public_key = b64decode(self.signer.public_key, validate=True) + if ( + evidence_runner_signer_sha256(public_key) + != self.payload.evidence_runner_signer_sha256 + ): + raise ValueError("terminal signer does not match the admitted runner") + return self + + def artifact_sha256(self) -> str: + return _sha256(self) + + +class ProductionTerminalVerificationExpected(ClosedSignedModel): + """Exact storage and runtime values read independently by the acceptor.""" + + run_id: str = Field(pattern=_UUID_RE) + flow_run_id_sha256: str = Field(pattern=_SHA256_RE) + tenant_id: str = Field(pattern=_UUID_RE) + workflow_id: str = Field(pattern=_UUID_RE) + workflow_version_id: str = Field(pattern=_UUID_RE) + bundle_version_id: str = Field(pattern=_UUID_RE) + bundle_artifact_sha256: str = Field(pattern=_SHA256_RE) + bundle_content_digest: str = Field(pattern=_SHA256_RE) + environment_digest: str = Field(pattern=_SHA256_RE) + environment_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_environment_sha256: str = Field(pattern=_SHA256_RE) + identity_contract_sha256: str = Field(pattern=_SHA256_RE) + effect_contract_sha256: str = Field(pattern=_SHA256_RE) + runtime_validation_id: str = Field(pattern=_UUID_RE) + runtime_substrate: Literal["web", "windows", "macos", "linux", "rdp", "citrix"] + admission_id: str = Field(pattern=_UUID_RE) + admission_artifact_sha256: str = Field(pattern=_SHA256_RE) + admission_policy_sha256: str = Field(pattern=_SHA256_RE) + evidence_identity_sha256: str = Field(pattern=_SHA256_RE) + admitted_runtime_build_sha256: str = Field(pattern=_SHA256_RE) + evidence_runner_signer_sha256: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_sha256: str = Field(pattern=_SHA256_RE) + qualification_signer_registry_revision: StrictInt = Field( + ge=1, le=JS_MAX_SAFE_INTEGER + ) + execution_authority_id: str = Field(pattern=_ID_RE) + execution_authority_sha256: str = Field(pattern=_SHA256_RE) + permit_chain_sha256: str = Field(pattern=_SHA256_RE) + permit_count: StrictInt = Field(ge=1, le=JS_MAX_SAFE_INTEGER) + final_authority_sequence: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + final_runtime_delivery_sequence: StrictInt = Field(ge=0, le=JS_MAX_SAFE_INTEGER) + workflow_contract_sha256: str = Field(pattern=_SHA256_RE) + execution_outcome_sha256: str = Field(pattern=_SHA256_RE) + run_receipt_sha256: str = Field(pattern=_SHA256_RE) + run_report_sha256: str = Field(pattern=_SHA256_RE) + run_report_object_version: str = Field(pattern=_ID_RE) + run_report_object_sha256: str = Field(pattern=_SHA256_RE) + evidence_manifests: ProductionEvidenceManifests + + +_EXPECTED_FIELDS: Final[tuple[str, ...]] = tuple( + field + for field in ProductionTerminalVerificationExpected.model_fields + if field != "permit_chain_sha256" +) + + +def sign_production_terminal_verification( + payload: ProductionTerminalVerificationPayload, + private_key: Ed25519PrivateKey, +) -> ProductionTerminalVerificationEnvelope: + public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + signer = EvidenceRunnerSigner( + key_id=evidence_runner_key_id(public_key), + public_key=b64encode(public_key).decode("ascii"), + ) + if ( + evidence_runner_signer_sha256(public_key) + != payload.evidence_runner_signer_sha256 + ): + raise ProductionTerminalVerificationError( + "terminal signer does not match the admitted runner" + ) + signature = private_key.sign(SIGNATURE_DOMAIN + payload.canonical_bytes()) + return ProductionTerminalVerificationEnvelope( + payload=payload, + signer=signer, + signature=urlsafe_b64encode(signature).decode("ascii").rstrip("="), + ) + + +def verify_production_terminal_verification( + envelope: ProductionTerminalVerificationEnvelope, + *, + expected: ProductionTerminalVerificationExpected, + now: datetime | None = None, +) -> str: + """Verify the runner proof against independent DB and object-store state.""" + + payload = envelope.payload + public_key_bytes = b64decode(envelope.signer.public_key, validate=True) + try: + Ed25519PublicKey.from_public_bytes(public_key_bytes).verify( + urlsafe_b64decode(envelope.signature + "=="), + SIGNATURE_DOMAIN + payload.canonical_bytes(), + ) + except (InvalidSignature, ValueError) as exc: + raise ProductionTerminalVerificationError( + "production terminal signature is invalid" + ) from exc + for field in _EXPECTED_FIELDS: + if getattr(payload, field) != getattr(expected, field): + raise ProductionTerminalVerificationError( + f"production terminal {field} does not match live state" + ) + if payload.permit_chain.permit_chain_sha256 != expected.permit_chain_sha256: + raise ProductionTerminalVerificationError( + "production terminal permit chain does not match live state" + ) + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + if _parse_utc(payload.issued_at, field="terminal issued_at") > current + timedelta( + minutes=5 + ): + raise ProductionTerminalVerificationError( + "production terminal proof is future-issued" + ) + return envelope.artifact_sha256() + + +def rebuild_production_delivery_permit_chain( + permits: tuple[ProductionDeliveryPermit, ...], +) -> ProductionDeliveryPermitChain: + """Rebuild the permit chain from independently retained permit records. + + The acceptor must not trust the ``permit_sha256`` or chain digest carried + inside the proof. It loads each retained permit artifact, recomputes its + digest, rebuilds the chain, and passes the rebuilt chain digest as + ``expected.permit_chain_sha256``. + """ + + return ProductionDeliveryPermitChain.build(permits) + + +def verify_production_terminal_verification_from_report( + envelope: ProductionTerminalVerificationEnvelope, + *, + report_bytes: bytes, + expected: ProductionTerminalVerificationExpected, + now: datetime | None = None, +) -> str: + """Verify the proof by re-deriving every projection from the retained report. + + ``report_bytes`` are the exact bytes of the immutable report object version + that the proof names (``run_report_object_version``), loaded independently + by the acceptor. The acceptor hashes these bytes, parses the typed + :class:`RunReport`, and reconstructs the outcome, receipt, and evidence + manifests. A proof whose projections do not equal the reconstruction is + refused even when its internal digests are self-consistent, so a signed + proof can never bind an arbitrary report hash to separately created + outcome, receipt, or manifest data. + """ + + payload = envelope.payload + digest = hashlib.sha256(report_bytes).hexdigest() + if digest != expected.run_report_object_sha256: + raise ProductionTerminalVerificationError( + "terminal report bytes do not match the retained report object" + ) + if digest != payload.run_report_sha256: + raise ProductionTerminalVerificationError( + "terminal proof does not bind the retained report bytes" + ) + try: + report = RunReport.model_validate_json(report_bytes) + except ValueError as exc: + raise ProductionTerminalVerificationError( + "terminal report bytes failed typed revalidation" + ) from exc + prepared = prepare_production_terminal_evidence(report) + if prepared.report_sha256 != digest: + raise ProductionTerminalVerificationError( + "terminal report bytes are not the canonical report encoding" + ) + if ( + prepared.flow_run_id_sha256 != payload.flow_run_id_sha256 + or prepared.bundle_content_digest != payload.bundle_content_digest + ): + raise ProductionTerminalVerificationError( + "terminal proof does not bind the retained report identity" + ) + if prepared.execution_outcome != payload.execution_outcome: + raise ProductionTerminalVerificationError( + "terminal execution outcome does not derive from the retained report" + ) + if ( + prepared.run_receipt != payload.run_receipt + or prepared.run_receipt_sha256 != payload.run_receipt_sha256 + ): + raise ProductionTerminalVerificationError( + "terminal run receipt does not derive from the retained report" + ) + manifests = build_production_evidence_manifests( + prepared, + admission_policy_sha256=payload.admission_policy_sha256, + environment_digest=payload.environment_digest, + environment_contract_sha256=payload.environment_contract_sha256, + runtime_environment_sha256=payload.runtime_environment_sha256, + identity_contract_sha256=payload.identity_contract_sha256, + effect_contract_sha256=payload.effect_contract_sha256, + admission_id=payload.admission_id, + admission_artifact_sha256=payload.admission_artifact_sha256, + execution_authority_id=payload.execution_authority_id, + execution_authority_sha256=payload.execution_authority_sha256, + permit_chain=payload.permit_chain, + ) + if manifests != payload.evidence_manifests: + raise ProductionTerminalVerificationError( + "terminal evidence manifests do not derive from the retained report" + ) + return verify_production_terminal_verification(envelope, expected=expected, now=now) diff --git a/tests/test_execution_profiles.py b/tests/test_execution_profiles.py index 6af25118..0753d479 100644 --- a/tests/test_execution_profiles.py +++ b/tests/test_execution_profiles.py @@ -15,6 +15,7 @@ classify_execution_outcome, execution_profile_contract, qualified_effect_requirements, + requires_signed_qualification_admission, stamp_execution_outcome, ) from openadapt_flow.ir import ( @@ -433,6 +434,22 @@ def test_standard_requires_durability_and_independent_effects(tmp_path): assert authorization.execution_profile == "standard" +@pytest.mark.parametrize( + "profile", [ExecutionProfile.STANDARD, ExecutionProfile.REGULATED] +) +def test_real_production_actuation_requires_signed_qualification(profile): + assert requires_signed_qualification_admission(profile, will_actuate=True) + assert not requires_signed_qualification_admission(profile, will_actuate=False) + + +def test_demo_is_the_only_named_unsigned_actuation_profile(): + assert not requires_signed_qualification_admission( + ExecutionProfile.DEMO, + will_actuate=True, + ) + assert not requires_signed_qualification_admission(None, will_actuate=True) + + def test_production_gate_rejects_immediate_screen_but_accepts_persisted_readback( tmp_path, ): diff --git a/tests/test_qualification_admission.py b/tests/test_qualification_admission.py index ed9d15bd..2b79b978 100644 --- a/tests/test_qualification_admission.py +++ b/tests/test_qualification_admission.py @@ -57,9 +57,13 @@ def _private_key() -> Ed25519PrivateKey: def _key_id(key: Ed25519PrivateKey | None = None) -> str: - public = (key or _private_key()).public_key().public_bytes( - serialization.Encoding.Raw, - serialization.PublicFormat.Raw, + public = ( + (key or _private_key()) + .public_key() + .public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) ) return qualification_signer_key_id(public) diff --git a/tests/test_qualification_admission_v2.py b/tests/test_qualification_admission_v2.py new file mode 100644 index 00000000..cc67764f --- /dev/null +++ b/tests/test_qualification_admission_v2.py @@ -0,0 +1,883 @@ +from __future__ import annotations + +import json +from base64 import b64encode +from datetime import datetime, timezone + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from pydantic import ValidationError + +from openadapt_flow.qualification_admission_v2 import ( + JS_MAX_SAFE_INTEGER, + PRODUCTION_ACCEPTANCE_ADMISSION_POLICY_SHA256, + AdmittedRuntimeBuildIdentity, + ManagedBrowserBuild, + ProductionAcceptanceEvidenceIdentity, + QualificationAdmissionError, + QualificationAdmissionExpected, + QualificationAdmissionPayload, + QualificationCampaignBinding, + QualificationCondition, + QualificationIssuer, + QualificationPermitTrustSnapshot, + QualificationSignerRegistry, + QualificationSignerRegistryCheckpoint, + RuntimeComponent, + RuntimeComponentManifest, + initialize_local_qualification_signer_checkpoint, + initialize_qualification_signer_registry_checkpoint, + load_local_qualification_signer_registry, + qualification_signer_key_id, + read_qualification_signer_checkpoint_file, + sign_qualification_admission, + validate_qualification_signer_registry_transition, + verify_qualification_admission, + verify_qualification_admission_for_actuation, +) +from openadapt_flow.qualification_campaign_permit import ( + QUALIFICATION_CAMPAIGN_EXECUTION_POLICY_SHA256, + QualificationCampaignPermitError, + QualificationCampaignPermitExpected, + QualificationCampaignPermitPayload, + QualificationCampaignTrialBinding, + sign_qualification_campaign_permit, + verify_qualification_campaign_permit, +) + +SHA_A = "a" * 64 +SHA_B = "b" * 64 +SHA_C = "c" * 64 +SHA_D = "d" * 64 +SHA_E = "e" * 64 +COMMIT = "a" * 40 +NOW = datetime(2026, 8, 18, 12, 0, tzinfo=timezone.utc) + +IDS = { + "admission_id": "00000000-0000-4000-8000-000000000001", + "tenant_id": "00000000-0000-4000-8000-000000000002", + "workflow_id": "00000000-0000-4000-8000-000000000003", + "workflow_version_id": "00000000-0000-4000-8000-000000000004", + "bundle_version_id": "00000000-0000-4000-8000-000000000004", + "runtime_validation_id": "00000000-0000-4000-8000-000000000005", + "campaign_id": "00000000-0000-4000-8000-000000000006", +} + + +def _private_key() -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes(bytes(range(32))) + + +def _public_key() -> bytes: + return ( + _private_key() + .public_key() + .public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ) + + +def _component( + name: str, + *, + roles: tuple[str, ...], + capabilities: tuple[str, ...], + artifact_sha256: str = SHA_A, + version: str = "1.2.3", +) -> RuntimeComponent: + return RuntimeComponent.model_validate( + { + "name": name, + "version": version, + "release_commit": COMMIT, + "artifact_sha256": artifact_sha256, + "roles": roles, + "capabilities": capabilities, + } + ) + + +def _runtime_build(substrate: str = "web") -> AdmittedRuntimeBuildIdentity: + flow = _component( + "openadapt-flow", + roles=("orchestrator",), + capabilities=("flow_runtime",), + ) + if substrate == "web": + components = ( + flow, + _component( + "openadapt-web-runner", + roles=("actuator", "observer", "orchestrator"), + capabilities=( + "browser_actuation", + "browser_observation", + "runner_wrapper", + ), + artifact_sha256=SHA_B, + version="report-v5", + ), + ) + browser = ManagedBrowserBuild( + playwright_version="1.55.0", + browser_base_image=f"openadapt/browser@sha256:{SHA_A}", + ) + os_family = "linux" + transport = "browser" + elif substrate in {"windows", "macos", "linux"}: + observer = { + "windows": "uia_observation", + "macos": "accessibility_observation", + "linux": "atspi_observation", + }[substrate] + components = tuple( + sorted( + ( + _component( + "openadapt-agent", + roles=("actuator", "orchestrator"), + capabilities=("native_actuation", "runner_wrapper"), + artifact_sha256=SHA_B, + version="report-v5", + ), + _component( + "openadapt-capture", + roles=("observer",), + capabilities=("native_capture", "pixel_observation"), + ), + flow, + _component( + f"openadapt-{observer}", + roles=("observer",), + capabilities=(observer,), + ), + ), + key=lambda item: item.name, + ) + ) + browser = None + os_family = substrate + transport = "local" + else: + components = ( + _component( + "openadapt-agent", + roles=("actuator", "orchestrator"), + capabilities=("native_actuation", "runner_wrapper"), + artifact_sha256=SHA_B, + version="report-v5", + ), + _component( + "openadapt-capture", + roles=("observer",), + capabilities=("native_capture", "pixel_observation"), + ), + flow, + _component( + "openadapt-remote-transport", + roles=("transport",), + capabilities=("remote_transport",), + ), + ) + browser = None + os_family = "windows" + transport = substrate + manifest = RuntimeComponentManifest(components=components) + return AdmittedRuntimeBuildIdentity.model_validate( + { + "substrate": substrate, + "flow_version": "1.2.3", + "flow_release_commit": COMMIT, + "flow_wheel_sha256": SHA_A, + "runtime_manifest": manifest, + "runtime_manifest_sha256": manifest.artifact_sha256(), + "runner_build": "report-v5", + "runner_artifact_sha256": SHA_B, + "managed_browser": browser, + "substrate_runtime": { + "os_family": os_family, + "transport": transport, + "runtime_boundary_sha256": SHA_C, + }, + } + ) + + +def _registry( + *, revision: int = 7, status: str = "active" +) -> QualificationSignerRegistry: + key = _public_key() + return QualificationSignerRegistry.model_validate( + { + "revision": revision, + "generated_at": "2026-08-18T11:00:00Z", + "expires_at": "2026-08-20T11:00:00Z", + "signers": [ + { + "algorithm": "ed25519", + "key_id": qualification_signer_key_id(key), + "public_key": b64encode(key).decode("ascii"), + "allowed_workflows": [ + "OpenAdaptAI/openadapt-internal/.github/workflows/production-qualification-admission.yml" + ], + "allowed_ref_prefixes": ["refs/heads/main@"], + "status": status, + "revoked_at": ( + None if status == "active" else "2026-08-18T10:00:00Z" + ), + } + ], + } + ) + + +def _payload( + registry: QualificationSignerRegistry, + *, + runtime_build: AdmittedRuntimeBuildIdentity | None = None, +) -> QualificationAdmissionPayload: + runtime_build = runtime_build or _runtime_build() + identity = ProductionAcceptanceEvidenceIdentity.model_validate( + { + "runtime_build_identity": runtime_build, + "evidence_runner_signer_sha256": SHA_B, + "deployment_manifest_sha256": SHA_D, + "qualification_signer_registry_sha256": registry.artifact_sha256(), + "qualification_signer_registry_revision": registry.revision, + **IDS, + "workflow_digest": SHA_A, + "bundle_artifact_sha256": SHA_B, + "bundle_content_digest": SHA_A, + "environment_digest": SHA_E, + "governed_authorization_template_sha256": SHA_A, + "application_contract_sha256": SHA_A, + "substrate_contract_sha256": SHA_A, + "environment_contract_sha256": SHA_A, + "runtime_environment_sha256": SHA_C, + "runtime_contract_sha256": SHA_A, + "input_policy_sha256": SHA_A, + "action_policy_sha256": SHA_A, + "network_policy_sha256": SHA_A, + "identity_contract_sha256": SHA_A, + "effect_contract_sha256": SHA_A, + "operator_contract_sha256": SHA_A, + "campaign_contract_sha256": SHA_C, + "oracle_contract_sha256": SHA_D, + "qualification_campaign_schema": "openadapt.qualification-campaign/v2", + "qualification_trial_schema": "openadapt.qualification-trial-row/v2", + "qualification_receipt_schema": "openadapt.qualification-evidence-receipt/v2", + "admission_policy_sha256": PRODUCTION_ACCEPTANCE_ADMISSION_POLICY_SHA256, + } + ) + campaign = QualificationCampaignBinding( + campaign_id=IDS["campaign_id"], + artifact_sha256=SHA_B, + contract_sha256=SHA_C, + outcomes_sha256=SHA_D, + oracle_id="independent-postcondition-oracle", + oracle_contract_sha256=SHA_D, + tasks=( + QualificationCondition( + task="submit-record", + condition="healthy", + required_trials=3, + observed_trials=3, + ), + ), + failure_taxonomy=( + "collateral_effect", + "duplicate_effect", + "healthy_path_model_call", + "operator_intervention", + "over_halt", + "platform_failure", + "safe_halt", + "silent_incorrect_success", + "uncertain_delivery", + "verified", + "wrong_record", + ), + ) + return QualificationAdmissionPayload.model_validate( + { + **{key: value for key, value in IDS.items() if key != "campaign_id"}, + "bundle_artifact_sha256": SHA_B, + "bundle_content_digest": SHA_A, + "environment_digest": SHA_E, + "governed_authorization_template_sha256": SHA_A, + "application_contract_sha256": SHA_A, + "substrate_contract_sha256": SHA_A, + "environment_contract_sha256": SHA_A, + "runtime_environment_sha256": SHA_C, + "runtime_contract_sha256": SHA_A, + "input_policy_sha256": SHA_A, + "action_policy_sha256": SHA_A, + "network_policy_sha256": SHA_A, + "identity_contract_sha256": SHA_A, + "effect_contract_sha256": SHA_A, + "operator_contract_sha256": SHA_A, + "evidence_identity": identity, + "campaign": campaign, + "issuer": QualificationIssuer( + key_id=qualification_signer_key_id(_public_key()), + workflow=( + "OpenAdaptAI/openadapt-internal/.github/workflows/" + "production-qualification-admission.yml" + ), + ref="refs/heads/main@" + COMMIT, + ), + "issued_at": "2026-08-18T11:30:00Z", + "not_before": "2026-08-18T11:30:00Z", + "expires_at": "2026-08-20T11:30:00Z", + } + ) + + +def _expected(payload: QualificationAdmissionPayload) -> QualificationAdmissionExpected: + return QualificationAdmissionExpected.model_validate( + { + "tenant_id": IDS["tenant_id"], + "workflow_id": IDS["workflow_id"], + "workflow_version_id": IDS["workflow_version_id"], + "bundle_version_id": IDS["bundle_version_id"], + "runtime_validation_id": IDS["runtime_validation_id"], + "bundle_artifact_sha256": SHA_B, + "bundle_content_digest": SHA_A, + "environment_digest": SHA_E, + "governed_authorization_template_sha256": SHA_A, + "application_contract_sha256": SHA_A, + "substrate_contract_sha256": SHA_A, + "environment_contract_sha256": SHA_A, + "runtime_environment_sha256": SHA_C, + "runtime_contract_sha256": SHA_A, + "input_policy_sha256": SHA_A, + "action_policy_sha256": SHA_A, + "network_policy_sha256": SHA_A, + "identity_contract_sha256": SHA_A, + "effect_contract_sha256": SHA_A, + "operator_contract_sha256": SHA_A, + "runtime_build_identity": payload.evidence_identity.runtime_build_identity, + "evidence_runner_signer_sha256": SHA_B, + "deployment_manifest_sha256": SHA_D, + } + ) + + +def _snapshot( + registry: QualificationSignerRegistry, +) -> QualificationPermitTrustSnapshot: + return QualificationPermitTrustSnapshot( + qualification_signer_registry_sha256=registry.artifact_sha256(), + qualification_signer_registry_revision=registry.revision, + qualification_signer_registry_checked_at="2026-08-18T11:59:30Z", + qualification_signer_registry_expires_at=registry.expires_at, + ) + + +def _campaign_permit_payload( + registry: QualificationSignerRegistry, +) -> QualificationCampaignPermitPayload: + runtime = _runtime_build("web") + trial = QualificationCampaignTrialBinding( + campaign_id=IDS["campaign_id"], + campaign_contract_sha256=SHA_A, + trial_id="00000000-0000-4000-8000-000000000007", + qualification_run_id="00000000-0000-4000-8000-000000000008", + task="submit-record", + condition="healthy", + trial_ordinal=1, + required_trials=3, + input_digest=SHA_B, + oracle_contract_sha256=SHA_C, + evidence_sink_sha256=SHA_D, + ) + return QualificationCampaignPermitPayload( + permit_id="00000000-0000-4000-8000-000000000009", + tenant_id=IDS["tenant_id"], + workflow_id=IDS["workflow_id"], + workflow_version_id=IDS["workflow_version_id"], + bundle_version_id=IDS["bundle_version_id"], + bundle_artifact_sha256=SHA_B, + bundle_content_digest=SHA_A, + environment_digest=SHA_E, + environment_contract_sha256=SHA_C, + runtime_environment_sha256=SHA_D, + runtime_build_identity=runtime, + runtime_build_identity_sha256=runtime.artifact_sha256(), + qualification_signer_registry_sha256=registry.artifact_sha256(), + qualification_signer_registry_revision=registry.revision, + trial=trial, + policy_sha256=QUALIFICATION_CAMPAIGN_EXECUTION_POLICY_SHA256, + issuer=QualificationIssuer( + key_id=qualification_signer_key_id(_public_key()), + workflow=( + "OpenAdaptAI/openadapt-internal/.github/workflows/" + "production-qualification-admission.yml" + ), + ref="refs/heads/main@" + COMMIT, + ), + issued_at="2026-08-18T11:30:00Z", + not_before="2026-08-18T11:30:00Z", + expires_at="2026-08-18T13:30:00Z", + ) + + +def _campaign_expected( + payload: QualificationCampaignPermitPayload, +) -> QualificationCampaignPermitExpected: + return QualificationCampaignPermitExpected( + tenant_id=payload.tenant_id, + workflow_id=payload.workflow_id, + workflow_version_id=payload.workflow_version_id, + bundle_version_id=payload.bundle_version_id, + bundle_artifact_sha256=payload.bundle_artifact_sha256, + bundle_content_digest=payload.bundle_content_digest, + environment_digest=payload.environment_digest, + environment_contract_sha256=payload.environment_contract_sha256, + runtime_environment_sha256=payload.runtime_environment_sha256, + runtime_build_identity=payload.runtime_build_identity, + trial=payload.trial, + ) + + +@pytest.mark.parametrize( + "substrate", ["web", "windows", "macos", "linux", "rdp", "citrix"] +) +def test_runtime_identity_covers_each_supported_substrate(substrate: str) -> None: + assert _runtime_build(substrate).substrate == substrate + + +def test_remote_runtime_rejects_structural_observer() -> None: + runtime = _runtime_build("rdp") + observer = _component( + "openadapt-uia-observer", + roles=("observer",), + capabilities=("uia_observation",), + ) + manifest = runtime.runtime_manifest.model_copy( + update={ + "components": tuple( + sorted( + (*runtime.runtime_manifest.components, observer), + key=lambda item: item.name, + ) + ) + } + ) + with pytest.raises(ValidationError, match="invalid capabilities"): + runtime.model_copy( + update={ + "runtime_manifest": manifest, + "runtime_manifest_sha256": manifest.artifact_sha256(), + } + ).__class__.model_validate( + runtime.model_copy( + update={ + "runtime_manifest": manifest, + "runtime_manifest_sha256": manifest.artifact_sha256(), + } + ).model_dump() + ) + + +def test_signed_admission_matches_live_environment_and_runtime() -> None: + registry = _registry() + payload = _payload(registry) + envelope = sign_qualification_admission(payload, _private_key()) + verified = verify_qualification_admission( + envelope, + registry=registry, + expected=_expected(payload), + now=NOW, + ) + assert verified.admission_artifact_sha256 == envelope.artifact_sha256() + assert verified.registry_revision == 7 + + +@pytest.mark.parametrize( + "replacement", + [ + [ + "collateral_effect", + "duplicate_effect", + "healthy_path_model_call", + "operator_intervention", + "over_halt", + "platform_failure", + "safe_halt", + "silent_incorrect_success", + "uncertain_delivery", + "verified", + ], + [ + "collateral_effect", + "duplicate_effect", + "healthy_path_model_call", + "operator_intervention", + "over_halt", + "platform_failure", + "safe_halt", + "silent_incorrect_success", + "uncertain_delivery", + "verified", + "wrong_record", + "unexpected", + ], + [ + "collateral_effect", + "duplicate_effect", + "healthy_path_model_call", + "operator_intervention", + "over_halt", + "platform_failure", + "safe_halt", + "silent_incorrect_success", + "uncertain_delivery", + "wrong_record", + "verified", + ], + [ + "collateral_effect", + "duplicate_effect", + "healthy_path_model_call", + "operator_intervention", + "over_halt", + "platform_failure", + "safe_halt", + "silent_incorrect_success", + "uncertain_delivery", + "verified", + "wrong_item", + ], + ], + ids=("omission", "addition", "reordering", "substitution"), +) +def test_campaign_requires_exact_policy_taxonomy(replacement: list[str]) -> None: + campaign = _payload(_registry()).campaign.model_dump(mode="json") + campaign["failure_taxonomy"] = replacement + with pytest.raises(ValidationError, match="taxonomy does not match policy"): + QualificationCampaignBinding.model_validate(campaign) + + +@pytest.mark.parametrize( + ("field", "replacement"), + [ + ("workflow", "OpenAdaptAI/alternate/.github/workflows/admit.yml"), + ("ref", "refs/heads/release@" + COMMIT), + ("ref", "refs/heads/main@" + "a" * 39), + ("ref", "refs/heads/main@" + "A" * 40), + ], +) +def test_admission_requires_fixed_policy_issuer(field: str, replacement: str) -> None: + data = _payload(_registry()).model_dump(mode="json") + data["issuer"][field] = replacement + with pytest.raises(ValidationError, match=f"issuer {field} does not match policy"): + QualificationAdmissionPayload.model_validate(data) + + +def test_live_environment_mismatch_is_refused() -> None: + registry = _registry() + payload = _payload(registry) + expected = _expected(payload).model_copy(update={"environment_digest": SHA_D}) + with pytest.raises( + QualificationAdmissionError, + match="environment_digest does not match the live run", + ): + verify_qualification_admission( + sign_qualification_admission(payload, _private_key()), + registry=registry, + expected=expected, + now=NOW, + ) + + +def test_revoked_signer_is_refused() -> None: + admission_registry = _registry() + payload = _payload(admission_registry) + revoked_registry = _registry(status="revoked") + identity = payload.evidence_identity.model_copy( + update={ + "qualification_signer_registry_sha256": revoked_registry.artifact_sha256() + } + ) + resigned = payload.model_copy(update={"evidence_identity": identity}) + with pytest.raises(QualificationAdmissionError, match="signer is revoked"): + verify_qualification_admission( + sign_qualification_admission(resigned, _private_key()), + registry=revoked_registry, + expected=_expected(resigned), + now=NOW, + ) + + +def test_actuation_rejects_stale_permit_registry_snapshot() -> None: + registry = _registry() + payload = _payload(registry) + stale = _snapshot(registry).model_copy( + update={"qualification_signer_registry_checked_at": "2026-08-18T11:58:00Z"} + ) + with pytest.raises(QualificationAdmissionError, match="snapshot is stale"): + verify_qualification_admission_for_actuation( + sign_qualification_admission(payload, _private_key()), + registry=registry, + expected=_expected(payload), + permit_trust_snapshot=stale, + now=NOW, + ) + + +def test_actuation_rechecks_exact_permit_registry_snapshot() -> None: + registry = _registry() + payload = _payload(registry) + verified = verify_qualification_admission_for_actuation( + sign_qualification_admission(payload, _private_key()), + registry=registry, + expected=_expected(payload), + permit_trust_snapshot=_snapshot(registry), + now=NOW, + ) + assert verified.registry_sha256 == registry.artifact_sha256() + + +def test_v1_admission_is_not_accepted_by_v2() -> None: + with pytest.raises(ValidationError): + QualificationAdmissionPayload.model_validate( + {"schema_version": "openadapt.qualification-admission/v1"} + ) + + +def _write_registry(path, registry: QualificationSignerRegistry) -> None: + path.write_text(json.dumps(registry.model_dump(mode="json"))) + path.chmod(0o600) + + +def test_local_checkpoint_initializes_once_and_rejects_rollback(tmp_path) -> None: + registry_path = tmp_path / "qualification-signers.json" + checkpoint_path = tmp_path / "qualification-signers.checkpoint.json" + _write_registry(registry_path, _registry(revision=7)) + + checkpoint = initialize_local_qualification_signer_checkpoint( + registry_path, checkpoint_path, now=NOW + ) + assert checkpoint.revision == 7 + assert read_qualification_signer_checkpoint_file(checkpoint_path) == checkpoint + + # Provisioning is one-time: an existing checkpoint refuses re-initialization. + with pytest.raises(QualificationAdmissionError, match="already exists"): + initialize_local_qualification_signer_checkpoint( + registry_path, checkpoint_path, now=NOW + ) + + # Same revision, same content: idempotent. + registry, advanced = load_local_qualification_signer_registry( + registry_path, checkpoint_path, now=NOW + ) + assert registry.revision == 7 + assert advanced == checkpoint + + # Rollback to a lower revision is refused. + _write_registry(registry_path, _registry(revision=6)) + with pytest.raises(QualificationAdmissionError, match="revision rolled back"): + load_local_qualification_signer_registry( + registry_path, checkpoint_path, now=NOW + ) + + # Same revision with changed content is equivocation. + _write_registry( + registry_path, + _registry(revision=7).model_copy(update={"expires_at": "2026-08-21T11:00:00Z"}), + ) + with pytest.raises(QualificationAdmissionError, match="changed content"): + load_local_qualification_signer_registry( + registry_path, checkpoint_path, now=NOW + ) + + # The durable checkpoint did not move under any refused transition. + assert read_qualification_signer_checkpoint_file(checkpoint_path) == checkpoint + + +def test_local_checkpoint_advances_and_survives_reload(tmp_path) -> None: + registry_path = tmp_path / "qualification-signers.json" + checkpoint_path = tmp_path / "qualification-signers.checkpoint.json" + _write_registry(registry_path, _registry(revision=7)) + initialize_local_qualification_signer_checkpoint( + registry_path, checkpoint_path, now=NOW + ) + + newer = QualificationSignerRegistry.model_validate( + { + **_registry(revision=8).model_dump(mode="json"), + "generated_at": "2026-08-18T11:30:00Z", + "expires_at": "2026-08-20T11:30:00Z", + } + ) + _write_registry(registry_path, newer) + _registry_loaded, advanced = load_local_qualification_signer_registry( + registry_path, checkpoint_path, now=NOW + ) + assert advanced.revision == 8 + + # The persisted checkpoint now refuses the older revision after a reload. + _write_registry(registry_path, _registry(revision=7)) + with pytest.raises(QualificationAdmissionError, match="revision rolled back"): + load_local_qualification_signer_registry( + registry_path, checkpoint_path, now=NOW + ) + + +def test_missing_durable_checkpoint_halts(tmp_path) -> None: + registry_path = tmp_path / "qualification-signers.json" + checkpoint_path = tmp_path / "qualification-signers.checkpoint.json" + _write_registry(registry_path, _registry(revision=7)) + with pytest.raises(QualificationAdmissionError, match="unavailable"): + load_local_qualification_signer_registry( + registry_path, checkpoint_path, now=NOW + ) + with pytest.raises(QualificationAdmissionError): + validate_qualification_signer_registry_transition( + _registry(revision=7), + prior_checkpoint=None, # type: ignore[arg-type] + now=NOW, + ) + + +def test_higher_revision_with_earlier_generation_time_rejects() -> None: + prior = initialize_qualification_signer_registry_checkpoint( + _registry(revision=7), now=NOW + ) + earlier = QualificationSignerRegistry.model_validate( + { + **_registry(revision=8).model_dump(mode="json"), + "generated_at": "2026-08-18T10:00:00Z", + "expires_at": "2026-08-20T10:00:00Z", + } + ) + with pytest.raises(QualificationAdmissionError, match="generation time"): + validate_qualification_signer_registry_transition( + earlier, prior_checkpoint=prior, now=NOW + ) + + +def test_expired_registry_never_advances_the_checkpoint() -> None: + prior = initialize_qualification_signer_registry_checkpoint( + _registry(revision=7), now=NOW + ) + expired_now = datetime(2026, 8, 21, 12, 0, tzinfo=timezone.utc) + with pytest.raises(QualificationAdmissionError, match="expired"): + validate_qualification_signer_registry_transition( + _registry(revision=8), prior_checkpoint=prior, now=expired_now + ) + + +def test_registry_file_rejects_unsafe_permissions(tmp_path) -> None: + registry_path = tmp_path / "qualification-signers.json" + checkpoint_path = tmp_path / "qualification-signers.checkpoint.json" + registry_path.write_text(json.dumps(_registry().model_dump(mode="json"))) + registry_path.chmod(0o644) + with pytest.raises(QualificationAdmissionError, match="permissions are unsafe"): + initialize_local_qualification_signer_checkpoint( + registry_path, checkpoint_path, now=NOW + ) + + +def test_registry_revision_rejects_unsafe_and_non_integer_values() -> None: + data = _registry().model_dump(mode="json") + for replacement in (JS_MAX_SAFE_INTEGER + 2, 7.0, "7", True): + data["revision"] = replacement + with pytest.raises(ValidationError): + QualificationSignerRegistry.model_validate(data) + + +def test_checkpoint_serializes_and_reloads_exactly() -> None: + checkpoint = initialize_qualification_signer_registry_checkpoint( + _registry(revision=7), now=NOW + ) + reloaded = QualificationSignerRegistryCheckpoint.model_validate_json( + checkpoint.model_dump_json() + ) + assert reloaded == checkpoint + + +def test_campaign_trial_permit_is_signed_and_nonproduction_only() -> None: + registry = _registry() + payload = _campaign_permit_payload(registry) + envelope = sign_qualification_campaign_permit(payload, _private_key()) + digest = verify_qualification_campaign_permit( + envelope, + registry=registry, + expected=_campaign_expected(payload), + now=NOW, + ) + assert digest == envelope.artifact_sha256() + assert payload.execution_purpose == "qualification_campaign" + assert payload.production_data_prohibited is True + assert payload.production_authority_prohibited is True + assert payload.production_success_prohibited is True + + +@pytest.mark.parametrize( + ("field", "replacement"), + [ + ("execution_purpose", "production"), + ("environment_class", "production"), + ("production_data_prohibited", False), + ("production_authority_prohibited", False), + ("production_success_prohibited", False), + # A numeric spelling of the fixed boolean must not pass either. + ("production_data_prohibited", 1), + ("production_authority_prohibited", 1), + ("production_success_prohibited", 1), + ], +) +def test_campaign_trial_permit_cannot_become_production( + field: str, + replacement: object, +) -> None: + data = _campaign_permit_payload(_registry()).model_dump(mode="json") + data[field] = replacement + with pytest.raises(ValidationError): + QualificationCampaignPermitPayload.model_validate(data) + + +def test_campaign_trial_permit_cannot_emit_success() -> None: + data = _campaign_permit_payload(_registry()).model_dump(mode="json") + data["status"] = "success" + with pytest.raises(ValidationError): + QualificationCampaignPermitPayload.model_validate(data) + + +def test_campaign_trial_permit_rejects_live_environment_change() -> None: + registry = _registry() + payload = _campaign_permit_payload(registry) + expected = _campaign_expected(payload).model_copy( + update={"environment_digest": SHA_A} + ) + with pytest.raises( + QualificationCampaignPermitError, + match="environment_digest does not match live state", + ): + verify_qualification_campaign_permit( + sign_qualification_campaign_permit(payload, _private_key()), + registry=registry, + expected=expected, + now=NOW, + ) + + +def test_campaign_trial_permit_is_one_shot() -> None: + registry = _registry() + payload = _campaign_permit_payload(registry) + with pytest.raises( + QualificationCampaignPermitError, + match="already consumed", + ): + verify_qualification_campaign_permit( + sign_qualification_campaign_permit(payload, _private_key()), + registry=registry, + expected=_campaign_expected(payload), + consumed_permit_ids={payload.permit_id}, + now=NOW, + ) diff --git a/tests/test_run_receipt.py b/tests/test_run_receipt.py index fdb651d7..26e06c67 100644 --- a/tests/test_run_receipt.py +++ b/tests/test_run_receipt.py @@ -8,6 +8,7 @@ from __future__ import annotations +import hashlib import json from pathlib import Path @@ -41,6 +42,19 @@ render_receipt_markdown, write_receipt, ) +from openadapt_flow.terminal_verification_v2 import ( + ProductionDeliveryPermit, + ProductionDeliveryPermitChain, + ProductionTerminalVerificationError, + ProductionTerminalVerificationExpected, + ProductionTerminalVerificationPayload, + build_production_evidence_manifests, + evidence_runner_signer_sha256, + prepare_production_terminal_evidence, + sign_production_terminal_verification, + verify_production_terminal_verification, + verify_production_terminal_verification_from_report, +) #: The complete set of keys a receipt may ever serialize. Adding one is a #: privacy-boundary change and must be a deliberate edit to this list. @@ -80,6 +94,252 @@ } +def test_terminal_preparation_revalidates_exact_report_and_integer_receipt() -> None: + prepared = prepare_production_terminal_evidence(_report(run_id_sha256="f" * 64)) + assert hashlib.sha256(prepared.report_bytes).hexdigest() == prepared.report_sha256 + assert prepared.flow_run_id_sha256 == "f" * 64 + assert prepared.run_receipt.est_cost_microusd == 0 + assert prepared.run_receipt.source_schema_version == "openadapt.run-receipt/v2" + assert prepared.execution_outcome.qualification_evidence_only is False + + +def test_terminal_evidence_manifests_recompute_from_retained_report() -> None: + prepared = prepare_production_terminal_evidence(_report(run_id_sha256="f" * 64)) + permit_chain = ProductionDeliveryPermitChain.build( + ( + ProductionDeliveryPermit( + execution_authority_id="authority:run:1", + execution_authority_sha256="1" * 64, + permit_id="permit:1", + permit_sha256="2" * 64, + run_request_sha256="3" * 64, + action_request_sha256="4" * 64, + admission_artifact_sha256="5" * 64, + evidence_identity_sha256="6" * 64, + environment_digest="7" * 64, + qualification_signer_registry_sha256="8" * 64, + qualification_signer_registry_revision=7, + qualification_signer_registry_checked_at="2026-08-18T11:59:00Z", + qualification_signer_registry_expires_at="2026-08-20T11:00:00Z", + input_edge_sequence=1, + authority_sequence=0, + runtime_delivery_sequence=9, + issued_at="2026-08-18T12:00:00Z", + delivered_at="2026-08-18T12:00:01Z", + ), + ) + ) + manifests = build_production_evidence_manifests( + prepared, + admission_policy_sha256="9" * 64, + environment_digest="7" * 64, + environment_contract_sha256="a" * 64, + runtime_environment_sha256="b" * 64, + identity_contract_sha256="c" * 64, + effect_contract_sha256="d" * 64, + admission_id="00000000-0000-4000-8000-000000000001", + admission_artifact_sha256="5" * 64, + execution_authority_id="authority:run:1", + execution_authority_sha256="1" * 64, + permit_chain=permit_chain, + ) + assert manifests.identity.required == 1 + assert ( + manifests.postcondition.records + == prepared.execution_outcome.postcondition_evidence + ) + assert manifests.effect.records[0].final_verdict == "confirmed" + + +def _terminal_permit_chain() -> ProductionDeliveryPermitChain: + return ProductionDeliveryPermitChain.build( + ( + ProductionDeliveryPermit( + execution_authority_id="authority:run:1", + execution_authority_sha256="1" * 64, + permit_id="permit:1", + permit_sha256="2" * 64, + run_request_sha256="3" * 64, + action_request_sha256="4" * 64, + admission_artifact_sha256="5" * 64, + evidence_identity_sha256="6" * 64, + environment_digest="7" * 64, + qualification_signer_registry_sha256="8" * 64, + qualification_signer_registry_revision=7, + qualification_signer_registry_checked_at="2026-08-18T11:59:00Z", + qualification_signer_registry_expires_at="2026-08-20T11:00:00Z", + input_edge_sequence=1, + authority_sequence=0, + runtime_delivery_sequence=9, + issued_at="2026-08-18T12:00:00Z", + delivered_at="2026-08-18T12:00:01Z", + ), + ) + ) + + +def _terminal_key(): + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + return Ed25519PrivateKey.from_private_bytes(bytes(range(32))) + + +def _terminal_payload_from_report() -> tuple[ + ProductionTerminalVerificationPayload, bytes +]: + prepared = prepare_production_terminal_evidence(_report(run_id_sha256="f" * 64)) + chain = _terminal_permit_chain() + manifests = build_production_evidence_manifests( + prepared, + admission_policy_sha256="9" * 64, + environment_digest="7" * 64, + environment_contract_sha256="a" * 64, + runtime_environment_sha256="b" * 64, + identity_contract_sha256="c" * 64, + effect_contract_sha256="d" * 64, + admission_id="00000000-0000-4000-8000-000000000001", + admission_artifact_sha256="5" * 64, + execution_authority_id="authority:run:1", + execution_authority_sha256="1" * 64, + permit_chain=chain, + ) + from cryptography.hazmat.primitives import serialization + + public_key = ( + _terminal_key() + .public_key() + .public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ) + payload = ProductionTerminalVerificationPayload( + run_id="00000000-0000-4000-8000-000000000002", + flow_run_id_sha256=prepared.flow_run_id_sha256, + tenant_id="00000000-0000-4000-8000-000000000003", + workflow_id="00000000-0000-4000-8000-000000000004", + workflow_version_id="00000000-0000-4000-8000-000000000005", + bundle_version_id="00000000-0000-4000-8000-000000000005", + bundle_artifact_sha256="b" * 64, + bundle_content_digest=prepared.bundle_content_digest, + environment_digest="7" * 64, + environment_contract_sha256="a" * 64, + runtime_environment_sha256="b" * 64, + identity_contract_sha256="c" * 64, + effect_contract_sha256="d" * 64, + runtime_validation_id="00000000-0000-4000-8000-000000000006", + runtime_substrate="web", + admission_id="00000000-0000-4000-8000-000000000001", + admission_artifact_sha256="5" * 64, + admission_policy_sha256="9" * 64, + evidence_identity_sha256="6" * 64, + admitted_runtime_build_sha256="0" * 64, + evidence_runner_signer_sha256=evidence_runner_signer_sha256(public_key), + qualification_signer_registry_sha256="8" * 64, + qualification_signer_registry_revision=7, + execution_authority_id="authority:run:1", + execution_authority_sha256="1" * 64, + permit_chain=chain, + permit_count=1, + final_authority_sequence=0, + final_runtime_delivery_sequence=9, + workflow_contract_sha256=prepared.execution_outcome.workflow_contract_sha256, + execution_outcome=prepared.execution_outcome, + execution_outcome_sha256=prepared.execution_outcome.artifact_sha256(), + run_receipt=prepared.run_receipt, + run_receipt_sha256=prepared.run_receipt_sha256, + run_report_sha256=prepared.report_sha256, + run_report_object_version="version:1", + run_report_object_sha256=prepared.report_sha256, + evidence_manifests=manifests, + verified_at="2026-08-18T12:00:02Z", + issued_at="2026-08-18T12:00:03Z", + ) + return payload, prepared.report_bytes + + +def _terminal_expected( + payload: ProductionTerminalVerificationPayload, +) -> ProductionTerminalVerificationExpected: + values = { + field: getattr(payload, field) + for field in ProductionTerminalVerificationExpected.model_fields + if field != "permit_chain_sha256" + } + values["permit_chain_sha256"] = payload.permit_chain.permit_chain_sha256 + return ProductionTerminalVerificationExpected.model_validate(values) + + +def test_terminal_proof_verifies_when_derived_from_retained_report() -> None: + payload, report_bytes = _terminal_payload_from_report() + envelope = sign_production_terminal_verification(payload, _terminal_key()) + digest = verify_production_terminal_verification_from_report( + envelope, + report_bytes=report_bytes, + expected=_terminal_expected(payload), + now=None, + ) + assert digest == envelope.artifact_sha256() + + +def test_terminal_proof_refuses_other_report_bytes() -> None: + payload, _report_bytes = _terminal_payload_from_report() + envelope = sign_production_terminal_verification(payload, _terminal_key()) + other = prepare_production_terminal_evidence( + _report(run_id_sha256="f" * 64, total_ms=9999.0) + ) + with pytest.raises( + ProductionTerminalVerificationError, + match="do not match the retained report object", + ): + verify_production_terminal_verification_from_report( + envelope, + report_bytes=other.report_bytes, + expected=_terminal_expected(payload), + ) + + +def test_terminal_proof_refuses_projection_not_derived_from_report() -> None: + """An internally consistent proof over foreign projections is refused. + + The plain field comparison cannot catch a payload whose receipt digests are + self-consistent but did not come from the retained report; the from-report + verifier must. + """ + + payload, report_bytes = _terminal_payload_from_report() + foreign_receipt = payload.run_receipt.model_copy(update={"duration_ms": 1}) + foreign = payload.model_copy( + update={ + "run_receipt": foreign_receipt, + "run_receipt_sha256": hashlib.sha256( + json.dumps( + foreign_receipt.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + ).hexdigest(), + } + ) + envelope = sign_production_terminal_verification(foreign, _terminal_key()) + # The plain verifier accepts it when the acceptor's expected state was + # (wrongly) copied from the proof itself... + verify_production_terminal_verification( + envelope, expected=_terminal_expected(foreign) + ) + # ...the derivation from the retained report bytes refuses it. + with pytest.raises( + ProductionTerminalVerificationError, + match="does not derive from the retained report", + ): + verify_production_terminal_verification_from_report( + envelope, + report_bytes=report_bytes, + expected=_terminal_expected(foreign), + ) + + def _postcondition_evidence( *, result_index: int = 0, diff --git a/tests/test_terminal_verification_v2.py b/tests/test_terminal_verification_v2.py new file mode 100644 index 00000000..4a7b076a --- /dev/null +++ b/tests/test_terminal_verification_v2.py @@ -0,0 +1,576 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from pydantic import ValidationError + +from openadapt_flow.ir import ( + ActionKind, + PostconditionContractEvidence, + postcondition_contract_sha256, + postcondition_step_contract_sha256, +) +from openadapt_flow.qualification_admission_v2 import canonical_json +from openadapt_flow.receipt import RunReceipt +from openadapt_flow.terminal_verification_v2 import ( + ProductionAuthorizationEvidenceManifest, + ProductionDeliveryPermit, + ProductionDeliveryPermitChain, + ProductionEffectEvidence, + ProductionEffectEvidenceManifest, + ProductionEvidenceManifests, + ProductionExecutionOutcome, + ProductionIdentityEvidenceManifest, + ProductionIdentityResult, + ProductionPolicyEvidenceManifest, + ProductionPostconditionEvidenceManifest, + ProductionRunReceipt, + ProductionTerminalVerificationError, + ProductionTerminalVerificationExpected, + ProductionTerminalVerificationPayload, + TerminalContractCounts, + build_evidence_manifest, + evidence_runner_signer_sha256, + project_production_run_receipt, + sign_production_terminal_verification, + verify_production_terminal_verification, +) + +SHA_A = "a" * 64 +SHA_B = "b" * 64 +SHA_C = "c" * 64 +SHA_D = "d" * 64 +SHA_E = "e" * 64 +NOW = datetime(2026, 8, 18, 12, 5, tzinfo=timezone.utc) + +IDS = { + "run_id": "00000000-0000-4000-8000-000000000001", + "tenant_id": "00000000-0000-4000-8000-000000000002", + "workflow_id": "00000000-0000-4000-8000-000000000003", + "workflow_version_id": "00000000-0000-4000-8000-000000000004", + "bundle_version_id": "00000000-0000-4000-8000-000000000004", + "runtime_validation_id": "00000000-0000-4000-8000-000000000005", + "admission_id": "00000000-0000-4000-8000-000000000006", +} + + +def _private_key() -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes(bytes(range(32))) + + +def _public_key() -> bytes: + return ( + _private_key() + .public_key() + .public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ) + + +def _postcondition() -> PostconditionContractEvidence: + step = postcondition_step_contract_sha256( + workflow_contract_sha256=SHA_A, + step_index=0, + action_kind=ActionKind.CLICK, + ) + contract = postcondition_contract_sha256( + workflow_contract_sha256=SHA_A, + step_contract_sha256=step, + action_kind=ActionKind.CLICK, + contract_kind="explicit_predicate", + contract_index=0, + ) + return PostconditionContractEvidence( + result_index=0, + workflow_contract_sha256=SHA_A, + step_index=0, + step_contract_sha256=step, + action_kind=ActionKind.CLICK, + contract_kind="explicit_predicate", + contract_index=0, + contract_sha256=contract, + verdict="passed", + ) + + +def _outcome() -> ProductionExecutionOutcome: + counts = TerminalContractCounts( + authorization=1, + identity=1, + postcondition=1, + effect=1, + ) + return ProductionExecutionOutcome( + profile="standard", + required_contracts=counts, + passed_contracts=counts, + workflow_contract_sha256=SHA_A, + postcondition_evidence=(_postcondition(),), + evidence_classes=( + "authorization", + "effect_tier_1", + "identity", + "postcondition", + ), + model_calls=0, + external_network_calls="none", + ) + + +def _receipt() -> ProductionRunReceipt: + unsigned = { + "schema_version": "openadapt.run-receipt/v2", + "outcome": "VERIFIED", + "transaction_outcome": "VERIFIED", + "profile": "standard", + "production_eligible": True, + "steps_total": 1, + "steps_ok": 1, + "heals": 0, + "model_calls": 0, + "est_cost_usd": 0.0, + "duration_ms": 100, + "rung_histogram": {"structural": 1}, + "evidence_classes": [ + "authorization", + "effect_tier_1", + "identity", + "postcondition", + ], + "effect_tier_reached": "independent_system", + "authorization_required": 1, + "authorization_confirmed": 1, + "identity_required": 1, + "identity_confirmed": 1, + "postconditions_required": 1, + "postconditions_confirmed": 1, + "effects_required": 1, + "effects_confirmed": 1, + "identity_armed": 1, + "identity_applicable": 1, + "over_halt_count": 0, + "substrate": "web", + "provenance": "production", + "receipt_builder_version": "1.2.3", + "external_network_calls": "none", + "bundle_digest": SHA_A, + "generated_at": "2026-08-18T12:00:00Z", + } + digest = hashlib.sha256( + json.dumps( + unsigned, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + ).hexdigest() + source = RunReceipt.model_validate({**unsigned, "receipt_digest": digest}) + return project_production_run_receipt(source) + + +def _permit_chain() -> ProductionDeliveryPermitChain: + permit = ProductionDeliveryPermit( + execution_authority_id="authority:run:1", + execution_authority_sha256=SHA_A, + permit_id="permit:1", + permit_sha256=SHA_B, + run_request_sha256=SHA_C, + action_request_sha256=SHA_D, + admission_artifact_sha256=SHA_D, + evidence_identity_sha256=SHA_E, + environment_digest=SHA_A, + qualification_signer_registry_sha256=SHA_E, + qualification_signer_registry_revision=7, + qualification_signer_registry_checked_at="2026-08-18T11:59:30Z", + qualification_signer_registry_expires_at="2026-08-20T11:00:00Z", + input_edge_sequence=1, + authority_sequence=0, + runtime_delivery_sequence=9, + issued_at="2026-08-18T12:00:00Z", + delivered_at="2026-08-18T12:00:01Z", + ) + return ProductionDeliveryPermitChain.build((permit,)) + + +def _manifests( + chain: ProductionDeliveryPermitChain, +) -> ProductionEvidenceManifests: + policy = build_evidence_manifest( + ProductionPolicyEvidenceManifest, + admission_policy_sha256=SHA_A, + governed_policy_contract_sha256=SHA_B, + governed_runtime_inputs_digest=SHA_C, + environment_digest=SHA_A, + environment_contract_sha256=SHA_B, + runtime_environment_sha256=SHA_C, + identity_contract_sha256=SHA_D, + effect_contract_sha256=SHA_E, + minimum_effect_tier=1, + ) + authorization = build_evidence_manifest( + ProductionAuthorizationEvidenceManifest, + governed_authorization_id_sha256=SHA_A, + admission_id=IDS["admission_id"], + admission_artifact_sha256=SHA_D, + execution_authority_id="authority:run:1", + execution_authority_sha256=SHA_A, + permit_chain_sha256=chain.permit_chain_sha256, + ) + identity = build_evidence_manifest( + ProductionIdentityEvidenceManifest, + identity_contract_sha256=SHA_D, + workflow_contract_sha256=SHA_A, + required=1, + confirmed=1, + results=( + ProductionIdentityResult( + result_index=0, + status="verified", + mode="structured", + signals=(), + ), + ), + ) + postcondition = build_evidence_manifest( + ProductionPostconditionEvidenceManifest, + workflow_contract_sha256=SHA_A, + required=1, + confirmed=1, + records=(_postcondition(),), + ) + effect = build_evidence_manifest( + ProductionEffectEvidenceManifest, + effect_contract_sha256=SHA_E, + workflow_contract_sha256=SHA_A, + required=1, + confirmed=1, + records=( + ProductionEffectEvidence( + result_index=0, + effect_contract_hash="sha256:" + SHA_A, + verifier_identity="sha256:" + SHA_B, + verification_tier=1, + final_verdict="confirmed", + observed_effect="present", + reconciliation_completed=False, + reconciliation_actions=0, + ), + ), + ) + return ProductionEvidenceManifests.model_validate( + { + "policy": policy, + "authorization": authorization, + "identity": identity, + "postcondition": postcondition, + "effect": effect, + } + ) + + +def _payload() -> ProductionTerminalVerificationPayload: + receipt = _receipt() + chain = _permit_chain() + return ProductionTerminalVerificationPayload( + **IDS, + flow_run_id_sha256=SHA_A, + bundle_artifact_sha256=SHA_B, + bundle_content_digest=SHA_A, + environment_digest=SHA_A, + environment_contract_sha256=SHA_B, + runtime_environment_sha256=SHA_C, + identity_contract_sha256=SHA_D, + effect_contract_sha256=SHA_E, + runtime_substrate="web", + admission_artifact_sha256=SHA_D, + admission_policy_sha256=SHA_A, + evidence_identity_sha256=SHA_E, + admitted_runtime_build_sha256=SHA_C, + evidence_runner_signer_sha256=evidence_runner_signer_sha256(_public_key()), + qualification_signer_registry_sha256=SHA_E, + qualification_signer_registry_revision=7, + execution_authority_id="authority:run:1", + execution_authority_sha256=SHA_A, + permit_chain=chain, + permit_count=1, + final_authority_sequence=0, + final_runtime_delivery_sequence=9, + workflow_contract_sha256=SHA_A, + execution_outcome=_outcome(), + execution_outcome_sha256=_outcome().artifact_sha256(), + run_receipt=receipt, + run_receipt_sha256=hashlib.sha256( + canonical_json(receipt.model_dump(mode="json")) + ).hexdigest(), + run_report_sha256=SHA_B, + run_report_object_version="version:1", + run_report_object_sha256=SHA_B, + evidence_manifests=_manifests(chain), + verified_at="2026-08-18T12:00:02Z", + issued_at="2026-08-18T12:00:03Z", + ) + + +def _expected( + payload: ProductionTerminalVerificationPayload, +) -> ProductionTerminalVerificationExpected: + return ProductionTerminalVerificationExpected( + run_id=payload.run_id, + flow_run_id_sha256=payload.flow_run_id_sha256, + tenant_id=payload.tenant_id, + workflow_id=payload.workflow_id, + workflow_version_id=payload.workflow_version_id, + bundle_version_id=payload.bundle_version_id, + bundle_artifact_sha256=payload.bundle_artifact_sha256, + bundle_content_digest=payload.bundle_content_digest, + environment_digest=payload.environment_digest, + environment_contract_sha256=payload.environment_contract_sha256, + runtime_environment_sha256=payload.runtime_environment_sha256, + identity_contract_sha256=payload.identity_contract_sha256, + effect_contract_sha256=payload.effect_contract_sha256, + runtime_validation_id=payload.runtime_validation_id, + runtime_substrate=payload.runtime_substrate, + admission_id=payload.admission_id, + admission_artifact_sha256=payload.admission_artifact_sha256, + admission_policy_sha256=payload.admission_policy_sha256, + evidence_identity_sha256=payload.evidence_identity_sha256, + admitted_runtime_build_sha256=payload.admitted_runtime_build_sha256, + evidence_runner_signer_sha256=payload.evidence_runner_signer_sha256, + qualification_signer_registry_sha256=payload.qualification_signer_registry_sha256, + qualification_signer_registry_revision=payload.qualification_signer_registry_revision, + execution_authority_id=payload.execution_authority_id, + execution_authority_sha256=payload.execution_authority_sha256, + permit_chain_sha256=payload.permit_chain.permit_chain_sha256, + permit_count=payload.permit_count, + final_authority_sequence=payload.final_authority_sequence, + final_runtime_delivery_sequence=payload.final_runtime_delivery_sequence, + workflow_contract_sha256=payload.workflow_contract_sha256, + execution_outcome_sha256=payload.execution_outcome_sha256, + run_receipt_sha256=payload.run_receipt_sha256, + run_report_sha256=payload.run_report_sha256, + run_report_object_version=payload.run_report_object_version, + run_report_object_sha256=payload.run_report_object_sha256, + evidence_manifests=payload.evidence_manifests, + ) + + +def test_terminal_v2_signs_and_verifies_exact_production_success() -> None: + payload = _payload() + envelope = sign_production_terminal_verification(payload, _private_key()) + digest = verify_production_terminal_verification( + envelope, + expected=_expected(payload), + now=NOW, + ) + assert digest == envelope.artifact_sha256() + assert ( + envelope.signature + == sign_production_terminal_verification(payload, _private_key()).signature + ) + + +def test_generic_success_cannot_parse_as_terminal_v2() -> None: + with pytest.raises(ValidationError): + ProductionTerminalVerificationPayload.model_validate({"status": "success"}) + + +@pytest.mark.parametrize( + ("path", "replacement"), + [ + ("qualification_evidence_only", True), + ("production_eligible", False), + ("execution_completed", False), + ("outcome", "COMPLETED_UNVERIFIED"), + ], +) +def test_terminal_refuses_nonproduction_outcome(path: str, replacement: object) -> None: + data = _outcome().model_dump(mode="json") + data[path] = replacement + with pytest.raises(ValidationError): + ProductionExecutionOutcome.model_validate(data) + + +def test_terminal_requires_nonzero_identity_postcondition_and_effect() -> None: + data = _outcome().model_dump(mode="json") + data["required_contracts"]["identity"] = 0 + data["passed_contracts"]["identity"] = 0 + with pytest.raises(ValidationError): + ProductionExecutionOutcome.model_validate(data) + + +def test_terminal_rejects_report_object_mismatch() -> None: + payload = _payload() + envelope = sign_production_terminal_verification(payload, _private_key()) + expected = _expected(payload).model_copy(update={"run_report_object_sha256": SHA_D}) + with pytest.raises( + ProductionTerminalVerificationError, + match="run_report_object_sha256 does not match live state", + ): + verify_production_terminal_verification( + envelope, + expected=expected, + now=NOW, + ) + + +def test_permit_rejects_stale_registry_check() -> None: + stale = _permit_chain().entries[0].model_dump(mode="json") + stale["qualification_signer_registry_checked_at"] = "2026-08-18T11:58:00Z" + with pytest.raises(ValidationError, match="registry check is stale"): + ProductionDeliveryPermit.model_validate(stale) + + +def test_permit_registry_check_at_sixty_seconds_is_accepted() -> None: + fresh = _permit_chain().entries[0].model_dump(mode="json") + fresh["qualification_signer_registry_checked_at"] = "2026-08-18T11:59:00Z" + assert ( + ProductionDeliveryPermit.model_validate(fresh).issued_at + == "2026-08-18T12:00:00Z" + ) + + +def test_terminal_counts_reject_numeric_boolean_and_float_spellings() -> None: + counts = {"authorization": 1, "identity": 1, "postcondition": 1, "effect": 1} + for field, replacement in ( + ("authorization", True), + ("identity", 1.0), + ("effect", "1"), + ): + with pytest.raises(ValidationError): + TerminalContractCounts.model_validate({**counts, field: replacement}) + data = _outcome().model_dump(mode="json") + data["qualification_evidence_only"] = 0 + with pytest.raises(ValidationError): + ProductionExecutionOutcome.model_validate(data) + data = _outcome().model_dump(mode="json") + data["production_eligible"] = 1 + with pytest.raises(ValidationError): + ProductionExecutionOutcome.model_validate(data) + + +def test_terminal_rejects_permit_sequence_gap() -> None: + first = _permit_chain().entries[0] + second = first.model_copy( + update={ + "permit_id": "permit:2", + "action_request_sha256": SHA_E, + "input_edge_sequence": 3, + "authority_sequence": 1, + "runtime_delivery_sequence": 10, + "issued_at": "2026-08-18T12:00:02Z", + "delivered_at": "2026-08-18T12:00:03Z", + } + ) + with pytest.raises(ValidationError, match="input-edge sequence"): + ProductionDeliveryPermitChain.build((first, second)) + + +def test_terminal_supports_multiple_input_edges_with_independent_sequences() -> None: + first = _permit_chain().entries[0] + second = first.model_copy( + update={ + "permit_id": "permit:2", + "permit_sha256": SHA_C, + "action_request_sha256": SHA_E, + "input_edge_sequence": 2, + "authority_sequence": 1, + "runtime_delivery_sequence": 10, + "issued_at": "2026-08-18T12:00:02Z", + "delivered_at": "2026-08-18T12:00:03Z", + } + ) + chain = ProductionDeliveryPermitChain.build((first, second)) + assert chain.entries[-1].authority_sequence == 1 + assert chain.entries[-1].runtime_delivery_sequence == 10 + + +def test_terminal_rejects_registry_change_between_input_edges() -> None: + first = _permit_chain().entries[0] + second = first.model_copy( + update={ + "permit_id": "permit:2", + "permit_sha256": SHA_C, + "action_request_sha256": SHA_E, + "qualification_signer_registry_revision": 8, + "input_edge_sequence": 2, + "authority_sequence": 1, + "runtime_delivery_sequence": 10, + "issued_at": "2026-08-18T12:00:02Z", + "delivered_at": "2026-08-18T12:00:03Z", + } + ) + with pytest.raises(ValidationError, match="changes its production authority"): + ProductionDeliveryPermitChain.build((first, second)) + + +def test_evidence_manifest_digest_is_recomputed() -> None: + manifest = _manifests(_permit_chain()).identity.model_dump(mode="json") + manifest["manifest_sha256"] = SHA_A + with pytest.raises(ValidationError, match="identity evidence digest is invalid"): + ProductionIdentityEvidenceManifest.model_validate(manifest) + + +@pytest.mark.parametrize( + ("field", "replacement", "message"), + [ + ("environment_digest", SHA_B, "qualification state"), + ("qualification_signer_registry_revision", 8, "qualification state"), + ("workflow_contract_sha256", SHA_B, "execution outcome digest"), + ("execution_outcome_sha256", SHA_B, "execution outcome digest"), + ( + "workflow_version_id", + "00000000-0000-4000-8000-000000000007", + "workflow and bundle versions", + ), + ], +) +def test_terminal_refuses_one_field_binding_mutation( + field: str, + replacement: object, + message: str, +) -> None: + data = _payload().model_dump(mode="json") + data[field] = replacement + with pytest.raises(ValidationError, match=message): + ProductionTerminalVerificationPayload.model_validate(data) + + +def test_terminal_rejects_wrong_evidence_signer() -> None: + payload = _payload().model_copy(update={"evidence_runner_signer_sha256": SHA_A}) + with pytest.raises( + ProductionTerminalVerificationError, + match="signer does not match", + ): + sign_production_terminal_verification(payload, _private_key()) + + +def test_terminal_rejects_mutated_signature() -> None: + payload = _payload() + envelope = sign_production_terminal_verification(payload, _private_key()) + replacement = "A" if envelope.signature[-1] != "A" else "B" + mutated = envelope.model_copy( + update={"signature": envelope.signature[:-1] + replacement} + ) + with pytest.raises( + ProductionTerminalVerificationError, + match="signature is invalid", + ): + verify_production_terminal_verification( + mutated, + expected=_expected(payload), + now=NOW, + ) + + +def test_terminal_v1_has_no_production_fallback() -> None: + data = _payload().model_dump(mode="json") + data["schema_version"] = "openadapt.production-terminal-verification/v1" + with pytest.raises(ValidationError): + ProductionTerminalVerificationPayload.model_validate(data)