From b5e354b703549f2287fb1fcaf558f7bcd3bfda1c Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 06:05:02 -0400 Subject: [PATCH 1/2] feat: add runner capability admission contract --- openadapt_types/__init__.py | 40 +- openadapt_types/execution_requirements.py | 277 ++++++++++++ openadapt_types/runner_capability.py | 330 ++++++++++++++ .../schemas/execution-requirements-v1.json | 213 +++++++++ .../runner-capability-manifest-v1.json | 217 +++++++++ .../runner-capability-match-v1.vector.json | 106 +++++ scripts/export_control_overlay_schemas.py | 116 +++++ tests/test_runner_capability.py | 428 ++++++++++++++++++ 8 files changed, 1726 insertions(+), 1 deletion(-) create mode 100644 openadapt_types/execution_requirements.py create mode 100644 openadapt_types/runner_capability.py create mode 100644 openadapt_types/schemas/execution-requirements-v1.json create mode 100644 openadapt_types/schemas/runner-capability-manifest-v1.json create mode 100644 openadapt_types/schemas/runner-capability-match-v1.vector.json create mode 100644 tests/test_runner_capability.py diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index fb861c5..b167d08 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -81,6 +81,14 @@ control_overlay_state_id_v2, ) from openadapt_types.episode import Episode, Step +from openadapt_types.execution_requirements import ( + CAPABILITY_MATCH_SCHEMA, + EXECUTION_REQUIREMENTS_SCHEMA, + CapabilityMatchV1, + CapabilityMismatchCode, + ExecutionRequirementsV1, + match_runner_capabilities, +) from openadapt_types.failure import FailureCategory, FailureRecord from openadapt_types.human_decision import ( HUMAN_DECISION_RECEIPT_REASONS, @@ -112,6 +120,18 @@ parse_action_json, to_benchmark_action_dict, ) +from openadapt_types.runner_capability import ( + RUNNER_CAPABILITY_MANIFEST_SCHEMA, + EffectVerificationTier, + ExecutionMode, + ExecutionProfile, + ExecutionSurface, + RunnerArchitecture, + RunnerCapability, + RunnerCapabilityLaneV1, + RunnerCapabilityManifestV1, + RunnerHostOS, +) try: __version__ = metadata.version("openadapt-types") @@ -120,7 +140,8 @@ # unknown so a caller cannot mistake a stale literal for the installed one. __version__ = "unknown" -__all__ = [ +# Keep the exports grouped by contract family for API review. +__all__ = [ # noqa: RUF022 # computer_state "BoundingBox", "ComputerState", @@ -177,6 +198,23 @@ # episode "Episode", "Step", + # runner capability and execution requirements + "CAPABILITY_MATCH_SCHEMA", + "EXECUTION_REQUIREMENTS_SCHEMA", + "RUNNER_CAPABILITY_MANIFEST_SCHEMA", + "CapabilityMatchV1", + "CapabilityMismatchCode", + "EffectVerificationTier", + "ExecutionMode", + "ExecutionProfile", + "ExecutionRequirementsV1", + "ExecutionSurface", + "RunnerArchitecture", + "RunnerCapability", + "RunnerCapabilityLaneV1", + "RunnerCapabilityManifestV1", + "RunnerHostOS", + "match_runner_capabilities", # failure "FailureCategory", "FailureRecord", diff --git a/openadapt_types/execution_requirements.py b/openadapt_types/execution_requirements.py new file mode 100644 index 0000000..8a3c8ac --- /dev/null +++ b/openadapt_types/execution_requirements.py @@ -0,0 +1,277 @@ +"""Closed execution requirements and pure runner-capability matching.""" + +from __future__ import annotations + +import hashlib +from datetime import datetime +from enum import Enum +from typing import Annotated, Any, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictInt, + StrictStr, + StringConstraints, + model_validator, +) + +from openadapt_types.runner_capability import ( + ExecutionMode, + ExecutionProfile, + ExecutionSurface, + RunnerCapability, + RunnerCapabilityManifestV1, + canonical_json_bytes, + parse_semantic_version, +) + +EXECUTION_REQUIREMENTS_SCHEMA: Literal["openadapt.execution-requirements/v1"] = ( + "openadapt.execution-requirements/v1" +) +CAPABILITY_MATCH_SCHEMA: Literal["openadapt.capability-match/v1"] = ( + "openadapt.capability-match/v1" +) + +_OPAQUE_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$" +_SHA256_PATTERN = r"^sha256:[0-9a-f]{64}$" +_SEMANTIC_VERSION_PATTERN = ( + r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\." + r"(0|[1-9][0-9]{0,8})$" +) + +OpaqueId = Annotated[ + str, + StringConstraints( + strict=True, + min_length=8, + max_length=128, + pattern=_OPAQUE_ID_PATTERN, + ), +] + + +class _StrictContract(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +def _sorted_unique_enums(values: tuple[Enum, ...], field_name: str) -> tuple[Any, ...]: + if len(set(values)) != len(values): + raise ValueError(f"{field_name} must not contain duplicates") + return tuple(sorted(values, key=lambda value: str(value.value))) + + +def _sorted_unique_strings(values: tuple[str, ...], field_name: str) -> tuple[str, ...]: + if len(set(values)) != len(values): + raise ValueError(f"{field_name} must not contain duplicates") + return tuple(sorted(values)) + + +class ExecutionRequirementsV1(_StrictContract): + """The exact runner requirements selected for one qualified plan.""" + + schema_version: Literal["openadapt.execution-requirements/v1"] + workflow_family_id: OpaqueId + portable_intent_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + selected_plan_id: OpaqueId + plan_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + qualification_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + binding_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + surface: ExecutionSurface + execution_mode: ExecutionMode + profile: ExecutionProfile + minimum_effect_tier: StrictInt = Field(ge=1, le=4) + required_capabilities: tuple[RunnerCapability, ...] = Field( + default=(), + max_length=len(RunnerCapability), + json_schema_extra={"uniqueItems": True}, + ) + permitted_runner_ids: tuple[OpaqueId, ...] = Field( + default=(), + max_length=128, + json_schema_extra={"uniqueItems": True}, + ) + permitted_executor_ids: tuple[OpaqueId, ...] = Field( + default=(), + max_length=128, + json_schema_extra={"uniqueItems": True}, + ) + minimum_runtime_version: StrictStr = Field( + min_length=5, + max_length=64, + pattern=_SEMANTIC_VERSION_PATTERN, + ) + maximum_runtime_version: StrictStr = Field( + min_length=5, + max_length=64, + pattern=_SEMANTIC_VERSION_PATTERN, + ) + authorization_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + runtime_input_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + + @model_validator(mode="after") + def _validate_and_canonicalize(self) -> ExecutionRequirementsV1: + minimum = parse_semantic_version(self.minimum_runtime_version) + maximum = parse_semantic_version(self.maximum_runtime_version) + if maximum < minimum: + raise ValueError( + "maximum_runtime_version must not be less than minimum_runtime_version" + ) + object.__setattr__( + self, + "required_capabilities", + _sorted_unique_enums( + self.required_capabilities, + "required_capabilities", + ), + ) + object.__setattr__( + self, + "permitted_runner_ids", + _sorted_unique_strings( + self.permitted_runner_ids, + "permitted_runner_ids", + ), + ) + object.__setattr__( + self, + "permitted_executor_ids", + _sorted_unique_strings( + self.permitted_executor_ids, + "permitted_executor_ids", + ), + ) + return self + + def canonical_payload(self) -> dict[str, Any]: + return self.model_dump(mode="json") + + def canonical_bytes(self) -> bytes: + return canonical_json_bytes(self.canonical_payload()) + + @property + def digest(self) -> str: + return "sha256:" + hashlib.sha256(self.canonical_bytes()).hexdigest() + + +class CapabilityMismatchCode(str, Enum): + MANIFEST_NOT_YET_VALID = "manifest_not_yet_valid" + MANIFEST_EXPIRED = "manifest_expired" + SURFACE_UNSUPPORTED = "surface_unsupported" + EXECUTION_MODE_UNSUPPORTED = "execution_mode_unsupported" + PROFILE_UNSUPPORTED = "profile_unsupported" + RUNTIME_VERSION_BELOW_MINIMUM = "runtime_version_below_minimum" + RUNTIME_VERSION_ABOVE_MAXIMUM = "runtime_version_above_maximum" + RUNNER_ID_UNASSIGNED = "runner_id_unassigned" + RUNNER_ID_NOT_PERMITTED = "runner_id_not_permitted" + EXECUTOR_ID_REQUIREMENT_UNSUPPORTED = "executor_id_requirement_unsupported" + MINIMUM_EFFECT_TIER_UNSUPPORTED = "minimum_effect_tier_unsupported" + REQUIRED_CAPABILITY_MISSING = "required_capability_missing" + + +class CapabilityMatchV1(_StrictContract): + """A deterministic, PHI-free result from one manifest/requirement match.""" + + schema_version: Literal["openadapt.capability-match/v1"] + manifest_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + requirements_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + matched: StrictBool + mismatch_codes: tuple[CapabilityMismatchCode, ...] = Field( + default=(), + max_length=len(CapabilityMismatchCode), + json_schema_extra={"uniqueItems": True}, + ) + missing_capabilities: tuple[RunnerCapability, ...] = Field( + default=(), + max_length=len(RunnerCapability), + json_schema_extra={"uniqueItems": True}, + ) + + @model_validator(mode="after") + def _validate_result(self) -> CapabilityMatchV1: + codes = _sorted_unique_enums(self.mismatch_codes, "mismatch_codes") + missing = _sorted_unique_enums( + self.missing_capabilities, + "missing_capabilities", + ) + object.__setattr__(self, "mismatch_codes", codes) + object.__setattr__(self, "missing_capabilities", missing) + has_failure = bool(codes or missing) + if self.matched == has_failure: + raise ValueError("matched must be true exactly when no mismatch exists") + has_missing_code = CapabilityMismatchCode.REQUIRED_CAPABILITY_MISSING in codes + if bool(missing) != has_missing_code: + raise ValueError( + "required_capability_missing must match missing_capabilities" + ) + return self + + +def match_runner_capabilities( + manifest: RunnerCapabilityManifestV1, + requirements: ExecutionRequirementsV1, + *, + at: datetime | str, +) -> CapabilityMatchV1: + """Match one manifest without I/O, hidden policy, or a system-clock read.""" + + codes: set[CapabilityMismatchCode] = set() + if manifest.is_not_yet_valid(at=at): + codes.add(CapabilityMismatchCode.MANIFEST_NOT_YET_VALID) + if manifest.is_expired(at=at): + codes.add(CapabilityMismatchCode.MANIFEST_EXPIRED) + lane = manifest.lane_for(requirements.surface, requirements.execution_mode) + if lane is None: + if requirements.surface not in manifest.supported_surfaces: + codes.add(CapabilityMismatchCode.SURFACE_UNSUPPORTED) + else: + codes.add(CapabilityMismatchCode.EXECUTION_MODE_UNSUPPORTED) + elif requirements.profile not in lane.supported_profiles: + codes.add(CapabilityMismatchCode.PROFILE_UNSUPPORTED) + + runtime_version = parse_semantic_version(manifest.flow_version) + if runtime_version < parse_semantic_version(requirements.minimum_runtime_version): + codes.add(CapabilityMismatchCode.RUNTIME_VERSION_BELOW_MINIMUM) + if runtime_version > parse_semantic_version(requirements.maximum_runtime_version): + codes.add(CapabilityMismatchCode.RUNTIME_VERSION_ABOVE_MAXIMUM) + + if requirements.permitted_runner_ids: + if manifest.runner_id is None: + codes.add(CapabilityMismatchCode.RUNNER_ID_UNASSIGNED) + elif manifest.runner_id not in requirements.permitted_runner_ids: + codes.add(CapabilityMismatchCode.RUNNER_ID_NOT_PERMITTED) + if requirements.permitted_executor_ids: + # A runner manifest does not attest an external executor identity. A + # later executor contract can satisfy this restriction. This matcher + # must not ignore it and admit an arbitrary executor. + codes.add(CapabilityMismatchCode.EXECUTOR_ID_REQUIREMENT_UNSUPPORTED) + + if lane is None: + missing: tuple[RunnerCapability, ...] = () + else: + best_effect_tier = lane.best_effect_tier() + if ( + best_effect_tier is None + or best_effect_tier > requirements.minimum_effect_tier + ): + codes.add(CapabilityMismatchCode.MINIMUM_EFFECT_TIER_UNSUPPORTED) + missing = tuple( + sorted( + set(requirements.required_capabilities) - set(lane.capabilities), + key=lambda capability: capability.value, + ) + ) + if missing: + codes.add(CapabilityMismatchCode.REQUIRED_CAPABILITY_MISSING) + + ordered_codes = tuple(sorted(codes, key=lambda code: code.value)) + return CapabilityMatchV1( + schema_version=CAPABILITY_MATCH_SCHEMA, + manifest_digest=manifest.digest, + requirements_digest=requirements.digest, + matched=not ordered_codes, + mismatch_codes=ordered_codes, + missing_capabilities=missing, + ) diff --git a/openadapt_types/runner_capability.py b/openadapt_types/runner_capability.py new file mode 100644 index 0000000..d1f4e63 --- /dev/null +++ b/openadapt_types/runner_capability.py @@ -0,0 +1,330 @@ +"""Closed, versioned capability advertisement for an OpenAdapt runner. + +This module contains a public interface only. It does not contain application +bindings, deployment policy, qualification data, thresholds, or effect-oracle +recipes. A manifest reports what one runner can supply. It is not proof that a +specific run used those capabilities; qualification and run evidence provide +that proof separately. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from datetime import datetime +from enum import Enum, IntEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictStr, model_validator + +RUNNER_CAPABILITY_MANIFEST_SCHEMA: Literal[ + "openadapt.runner-capability-manifest/v1" +] = "openadapt.runner-capability-manifest/v1" + +_OPAQUE_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$" +_SEMANTIC_VERSION_PATTERN = ( + r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\." + r"(0|[1-9][0-9]{0,8})$" +) +_TIMESTAMP_PATTERN = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$" + + +class _StrictContract(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class ExecutionSurface(str, Enum): + WEB = "web" + WINDOWS = "windows" + MACOS = "macos" + LINUX = "linux" + RDP = "rdp" + CITRIX = "citrix" + + +class ExecutionMode(str, Enum): + IN_SESSION = "in_session" + EXTERNAL = "external" + + +class ExecutionProfile(str, Enum): + DEMO = "demo" + STANDARD = "standard" + REGULATED = "regulated" + + +class RunnerHostOS(str, Enum): + WINDOWS = "windows" + MACOS = "macos" + LINUX = "linux" + + +class RunnerArchitecture(str, Enum): + X86_64 = "x86_64" + AARCH64 = "aarch64" + + +class EffectVerificationTier(IntEnum): + """Evidence strength, where 1 is strongest and 4 is weakest.""" + + INDEPENDENT_SYSTEM_INTERFACE = 1 + INDEPENDENT_SESSION = 2 + PERSISTED_STATE_REACQUISITION = 3 + IMMEDIATE_SCREEN_CONFIRMATION = 4 + + +class RunnerCapability(str, Enum): + """Closed capabilities that admission can require from a runner.""" + + # Observation and resolution. + PIXEL_OBSERVATION = "pixel_observation" + STRUCTURAL_OBSERVATION = "structural_observation" + PLAYWRIGHT_DOM = "playwright_dom" + STRUCTURAL_RESOLUTION = "structural_resolution" + VISUAL_RESOLUTION = "visual_resolution" + OCR_RELATIONAL_RESOLUTION = "ocr_relational_resolution" + + # Actuation. + ACTUATION = "actuation" + PHYSICAL_INPUT_ACTUATION = "physical_input_actuation" + STRUCTURAL_ACTUATION = "structural_actuation" + PLAYWRIGHT_ACTUATION = "playwright_actuation" + API_ACTUATION = "api_actuation" + EXTERNAL_EXECUTOR_ACTUATION = "external_executor_actuation" + + # Identity and workflow state. + APPLICATION_IDENTITY = "application_identity" + SESSION_IDENTITY = "session_identity" + WORKFLOW_STATE_IDENTITY = "workflow_state_identity" + RECORD_IDENTITY = "record_identity" + IDENTITY_VERIFICATION = "identity_verification" + + # Run continuity and evidence. + GOVERNED_AUTHORIZATION = "governed_authorization" + SETTLED_STATE_DETECTION = "settled_state_detection" + SESSION_CONTINUITY = "session_continuity" + DURABLE_RESUME = "durable_resume" + POSTCONDITION_VERIFICATION = "postcondition_verification" + EVIDENCE_EXPORT = "evidence_export" + + # Effect-verification strength. + EFFECT_VERIFICATION = "effect_verification" + INDEPENDENT_SYSTEM_OF_RECORD = "independent_system_of_record" + INDEPENDENT_SESSION = "independent_session" + PERSISTED_STATE_REACQUISITION = "persisted_state_reacquisition" + IMMEDIATE_SCREEN_CONFIRMATION = "immediate_screen_confirmation" + EFFECT_TIER_1 = "effect_tier_1" + EFFECT_TIER_2 = "effect_tier_2" + EFFECT_TIER_3 = "effect_tier_3" + EFFECT_TIER_4 = "effect_tier_4" + + # Secret, parameter, and network boundaries. + LOCAL_SECRET_RESOLUTION = "local_secret_resolution" + PARAMETER_BY_REFERENCE = "parameter_by_reference" + NO_EXTERNAL_EGRESS = "no_external_egress" + POLICY_CONTROLLED_EGRESS = "policy_controlled_egress" + + +_EFFECT_CAPABILITIES_BY_TIER: Mapping[ + EffectVerificationTier, tuple[RunnerCapability, ...] +] = { + EffectVerificationTier.INDEPENDENT_SYSTEM_INTERFACE: ( + RunnerCapability.EFFECT_TIER_1, + RunnerCapability.INDEPENDENT_SYSTEM_OF_RECORD, + ), + EffectVerificationTier.INDEPENDENT_SESSION: ( + RunnerCapability.EFFECT_TIER_2, + RunnerCapability.INDEPENDENT_SESSION, + ), + EffectVerificationTier.PERSISTED_STATE_REACQUISITION: ( + RunnerCapability.EFFECT_TIER_3, + RunnerCapability.PERSISTED_STATE_REACQUISITION, + ), + EffectVerificationTier.IMMEDIATE_SCREEN_CONFIRMATION: ( + RunnerCapability.EFFECT_TIER_4, + RunnerCapability.IMMEDIATE_SCREEN_CONFIRMATION, + ), +} + + +def _parse_timestamp(value: str, field_name: str) -> datetime: + if re.fullmatch(_TIMESTAMP_PATTERN, value) is None: + raise ValueError(f"{field_name} must be an RFC 3339 timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{field_name} must be an RFC 3339 timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError(f"{field_name} must include a timezone") + return parsed + + +def parse_semantic_version(value: str) -> tuple[int, int, int]: + """Return a comparable released semantic version or raise ``ValueError``.""" + + if re.fullmatch(_SEMANTIC_VERSION_PATTERN, value) is None: + raise ValueError("version must have the form MAJOR.MINOR.PATCH") + return tuple(int(part) for part in value.split(".")) # type: ignore[return-value] + + +def canonical_json_bytes(payload: Mapping[str, Any]) -> bytes: + """Return the language-neutral canonical JSON form used by this contract.""" + + return json.dumps( + payload, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def _canonical_tuple(values: tuple[Enum, ...], field_name: str) -> tuple[Any, ...]: + if len(set(values)) != len(values): + raise ValueError(f"{field_name} must not contain duplicates") + return tuple(sorted(values, key=lambda value: str(value.value))) + + +class RunnerCapabilityLaneV1(_StrictContract): + """Capabilities bound to one exact surface and execution mode.""" + + surface: ExecutionSurface + execution_mode: ExecutionMode + capabilities: tuple[RunnerCapability, ...] = Field( + min_length=1, + max_length=len(RunnerCapability), + json_schema_extra={"uniqueItems": True}, + ) + supported_profiles: tuple[ExecutionProfile, ...] = Field( + min_length=1, + max_length=len(ExecutionProfile), + json_schema_extra={"uniqueItems": True}, + ) + + @model_validator(mode="after") + def _validate_and_canonicalize(self) -> RunnerCapabilityLaneV1: + object.__setattr__( + self, + "capabilities", + _canonical_tuple(self.capabilities, "capabilities"), + ) + object.__setattr__( + self, + "supported_profiles", + _canonical_tuple(self.supported_profiles, "supported_profiles"), + ) + return self + + def best_effect_tier(self) -> EffectVerificationTier | None: + """Return the strongest tier supplied by this lane.""" + + supported = [ + tier + for tier, capabilities in _EFFECT_CAPABILITIES_BY_TIER.items() + if any(capability in self.capabilities for capability in capabilities) + ] + return min(supported) if supported else None + + +class RunnerCapabilityManifestV1(_StrictContract): + """A PHI-free, time-bounded advertisement from one runner installation.""" + + schema_version: Literal["openadapt.runner-capability-manifest/v1"] + installation_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + runner_id: StrictStr | None = Field(default=None, pattern=_OPAQUE_ID_PATTERN) + agent_version: StrictStr = Field( + min_length=5, + max_length=64, + pattern=_SEMANTIC_VERSION_PATTERN, + ) + flow_version: StrictStr = Field( + min_length=5, + max_length=64, + pattern=_SEMANTIC_VERSION_PATTERN, + ) + host_os: RunnerHostOS + architecture: RunnerArchitecture + lanes: tuple[RunnerCapabilityLaneV1, ...] = Field( + min_length=1, + max_length=len(ExecutionSurface) * len(ExecutionMode), + json_schema_extra={"uniqueItems": True}, + ) + generated_at: StrictStr = Field( + min_length=20, + max_length=40, + pattern=_TIMESTAMP_PATTERN, + ) + expires_at: StrictStr = Field( + min_length=20, + max_length=40, + pattern=_TIMESTAMP_PATTERN, + ) + + @model_validator(mode="after") + def _validate_and_canonicalize(self) -> RunnerCapabilityManifestV1: + parse_semantic_version(self.agent_version) + parse_semantic_version(self.flow_version) + generated_at = _parse_timestamp(self.generated_at, "generated_at") + expires_at = _parse_timestamp(self.expires_at, "expires_at") + if expires_at <= generated_at: + raise ValueError("expires_at must be later than generated_at") + + lane_keys = [(lane.surface, lane.execution_mode) for lane in self.lanes] + if len(set(lane_keys)) != len(lane_keys): + raise ValueError("lanes must have unique surface and execution-mode pairs") + object.__setattr__( + self, + "lanes", + tuple( + sorted( + self.lanes, + key=lambda lane: (lane.surface.value, lane.execution_mode.value), + ) + ), + ) + return self + + def canonical_payload(self) -> dict[str, Any]: + return self.model_dump(mode="json") + + def canonical_bytes(self) -> bytes: + return canonical_json_bytes(self.canonical_payload()) + + @property + def digest(self) -> str: + return "sha256:" + hashlib.sha256(self.canonical_bytes()).hexdigest() + + def is_expired(self, *, at: datetime | str) -> bool: + """Compare expiry with an explicit instant without reading the clock.""" + + instant = _parse_timestamp(at, "at") if isinstance(at, str) else at + if instant.tzinfo is None or instant.utcoffset() is None: + raise ValueError("at must include a timezone") + return instant >= _parse_timestamp(self.expires_at, "expires_at") + + def is_not_yet_valid(self, *, at: datetime | str) -> bool: + """Reject a manifest before its explicit generation instant.""" + + instant = _parse_timestamp(at, "at") if isinstance(at, str) else at + if instant.tzinfo is None or instant.utcoffset() is None: + raise ValueError("at must include a timezone") + return instant < _parse_timestamp(self.generated_at, "generated_at") + + @property + def supported_surfaces(self) -> tuple[ExecutionSurface, ...]: + return tuple( + sorted({lane.surface for lane in self.lanes}, key=lambda item: item.value) + ) + + def lane_for( + self, surface: ExecutionSurface, execution_mode: ExecutionMode + ) -> RunnerCapabilityLaneV1 | None: + return next( + ( + lane + for lane in self.lanes + if lane.surface == surface and lane.execution_mode == execution_mode + ), + None, + ) diff --git a/openadapt_types/schemas/execution-requirements-v1.json b/openadapt_types/schemas/execution-requirements-v1.json new file mode 100644 index 0000000..92a2056 --- /dev/null +++ b/openadapt_types/schemas/execution-requirements-v1.json @@ -0,0 +1,213 @@ +{ + "$defs": { + "ExecutionMode": { + "enum": [ + "in_session", + "external" + ], + "title": "ExecutionMode", + "type": "string" + }, + "ExecutionProfile": { + "enum": [ + "demo", + "standard", + "regulated" + ], + "title": "ExecutionProfile", + "type": "string" + }, + "ExecutionSurface": { + "enum": [ + "web", + "windows", + "macos", + "linux", + "rdp", + "citrix" + ], + "title": "ExecutionSurface", + "type": "string" + }, + "RunnerCapability": { + "description": "Closed capabilities that admission can require from a runner.", + "enum": [ + "pixel_observation", + "structural_observation", + "playwright_dom", + "structural_resolution", + "visual_resolution", + "ocr_relational_resolution", + "actuation", + "physical_input_actuation", + "structural_actuation", + "playwright_actuation", + "api_actuation", + "external_executor_actuation", + "application_identity", + "session_identity", + "workflow_state_identity", + "record_identity", + "identity_verification", + "governed_authorization", + "settled_state_detection", + "session_continuity", + "durable_resume", + "postcondition_verification", + "evidence_export", + "effect_verification", + "independent_system_of_record", + "independent_session", + "persisted_state_reacquisition", + "immediate_screen_confirmation", + "effect_tier_1", + "effect_tier_2", + "effect_tier_3", + "effect_tier_4", + "local_secret_resolution", + "parameter_by_reference", + "no_external_egress", + "policy_controlled_egress" + ], + "title": "RunnerCapability", + "type": "string" + } + }, + "additionalProperties": false, + "description": "The exact runner requirements selected for one qualified plan.", + "properties": { + "authorization_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Authorization Digest", + "type": "string" + }, + "binding_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Binding Digest", + "type": "string" + }, + "execution_mode": { + "$ref": "#/$defs/ExecutionMode" + }, + "maximum_runtime_version": { + "maxLength": 64, + "minLength": 5, + "pattern": "^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$", + "title": "Maximum Runtime Version", + "type": "string" + }, + "minimum_effect_tier": { + "maximum": 4, + "minimum": 1, + "title": "Minimum Effect Tier", + "type": "integer" + }, + "minimum_runtime_version": { + "maxLength": 64, + "minLength": 5, + "pattern": "^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$", + "title": "Minimum Runtime Version", + "type": "string" + }, + "permitted_executor_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 8, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 128, + "title": "Permitted Executor Ids", + "type": "array", + "uniqueItems": true + }, + "permitted_runner_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 8, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 128, + "title": "Permitted Runner Ids", + "type": "array", + "uniqueItems": true + }, + "plan_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Plan Digest", + "type": "string" + }, + "portable_intent_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Portable Intent Digest", + "type": "string" + }, + "profile": { + "$ref": "#/$defs/ExecutionProfile" + }, + "qualification_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Qualification Digest", + "type": "string" + }, + "required_capabilities": { + "default": [], + "items": { + "$ref": "#/$defs/RunnerCapability" + }, + "maxItems": 36, + "title": "Required Capabilities", + "type": "array", + "uniqueItems": true + }, + "runtime_input_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Runtime Input Digest", + "type": "string" + }, + "schema_version": { + "const": "openadapt.execution-requirements/v1", + "title": "Schema Version", + "type": "string" + }, + "selected_plan_id": { + "maxLength": 128, + "minLength": 8, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Selected Plan Id", + "type": "string" + }, + "surface": { + "$ref": "#/$defs/ExecutionSurface" + }, + "workflow_family_id": { + "maxLength": 128, + "minLength": 8, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Workflow Family Id", + "type": "string" + } + }, + "required": [ + "schema_version", + "workflow_family_id", + "portable_intent_digest", + "selected_plan_id", + "plan_digest", + "qualification_digest", + "binding_digest", + "surface", + "execution_mode", + "profile", + "minimum_effect_tier", + "minimum_runtime_version", + "maximum_runtime_version", + "authorization_digest", + "runtime_input_digest" + ], + "title": "ExecutionRequirementsV1", + "type": "object" +} diff --git a/openadapt_types/schemas/runner-capability-manifest-v1.json b/openadapt_types/schemas/runner-capability-manifest-v1.json new file mode 100644 index 0000000..30749c8 --- /dev/null +++ b/openadapt_types/schemas/runner-capability-manifest-v1.json @@ -0,0 +1,217 @@ +{ + "$defs": { + "ExecutionMode": { + "enum": [ + "in_session", + "external" + ], + "title": "ExecutionMode", + "type": "string" + }, + "ExecutionProfile": { + "enum": [ + "demo", + "standard", + "regulated" + ], + "title": "ExecutionProfile", + "type": "string" + }, + "ExecutionSurface": { + "enum": [ + "web", + "windows", + "macos", + "linux", + "rdp", + "citrix" + ], + "title": "ExecutionSurface", + "type": "string" + }, + "RunnerArchitecture": { + "enum": [ + "x86_64", + "aarch64" + ], + "title": "RunnerArchitecture", + "type": "string" + }, + "RunnerCapability": { + "description": "Closed capabilities that admission can require from a runner.", + "enum": [ + "pixel_observation", + "structural_observation", + "playwright_dom", + "structural_resolution", + "visual_resolution", + "ocr_relational_resolution", + "actuation", + "physical_input_actuation", + "structural_actuation", + "playwright_actuation", + "api_actuation", + "external_executor_actuation", + "application_identity", + "session_identity", + "workflow_state_identity", + "record_identity", + "identity_verification", + "governed_authorization", + "settled_state_detection", + "session_continuity", + "durable_resume", + "postcondition_verification", + "evidence_export", + "effect_verification", + "independent_system_of_record", + "independent_session", + "persisted_state_reacquisition", + "immediate_screen_confirmation", + "effect_tier_1", + "effect_tier_2", + "effect_tier_3", + "effect_tier_4", + "local_secret_resolution", + "parameter_by_reference", + "no_external_egress", + "policy_controlled_egress" + ], + "title": "RunnerCapability", + "type": "string" + }, + "RunnerCapabilityLaneV1": { + "additionalProperties": false, + "description": "Capabilities bound to one exact surface and execution mode.", + "properties": { + "capabilities": { + "items": { + "$ref": "#/$defs/RunnerCapability" + }, + "maxItems": 36, + "minItems": 1, + "title": "Capabilities", + "type": "array", + "uniqueItems": true + }, + "execution_mode": { + "$ref": "#/$defs/ExecutionMode" + }, + "supported_profiles": { + "items": { + "$ref": "#/$defs/ExecutionProfile" + }, + "maxItems": 3, + "minItems": 1, + "title": "Supported Profiles", + "type": "array", + "uniqueItems": true + }, + "surface": { + "$ref": "#/$defs/ExecutionSurface" + } + }, + "required": [ + "surface", + "execution_mode", + "capabilities", + "supported_profiles" + ], + "title": "RunnerCapabilityLaneV1", + "type": "object" + }, + "RunnerHostOS": { + "enum": [ + "windows", + "macos", + "linux" + ], + "title": "RunnerHostOS", + "type": "string" + } + }, + "additionalProperties": false, + "description": "A PHI-free, time-bounded advertisement from one runner installation.", + "properties": { + "agent_version": { + "maxLength": 64, + "minLength": 5, + "pattern": "^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$", + "title": "Agent Version", + "type": "string" + }, + "architecture": { + "$ref": "#/$defs/RunnerArchitecture" + }, + "expires_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$", + "title": "Expires At", + "type": "string" + }, + "flow_version": { + "maxLength": 64, + "minLength": 5, + "pattern": "^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$", + "title": "Flow Version", + "type": "string" + }, + "generated_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$", + "title": "Generated At", + "type": "string" + }, + "host_os": { + "$ref": "#/$defs/RunnerHostOS" + }, + "installation_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Installation Id", + "type": "string" + }, + "lanes": { + "items": { + "$ref": "#/$defs/RunnerCapabilityLaneV1" + }, + "maxItems": 12, + "minItems": 1, + "title": "Lanes", + "type": "array", + "uniqueItems": true + }, + "runner_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Runner Id" + }, + "schema_version": { + "const": "openadapt.runner-capability-manifest/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "schema_version", + "installation_id", + "agent_version", + "flow_version", + "host_os", + "architecture", + "lanes", + "generated_at", + "expires_at" + ], + "title": "RunnerCapabilityManifestV1", + "type": "object" +} diff --git a/openadapt_types/schemas/runner-capability-match-v1.vector.json b/openadapt_types/schemas/runner-capability-match-v1.vector.json new file mode 100644 index 0000000..9755dd5 --- /dev/null +++ b/openadapt_types/schemas/runner-capability-match-v1.vector.json @@ -0,0 +1,106 @@ +{ + "expected_match": { + "manifest_digest": "sha256:acc05cdf064569d93df594df151b01e7d1740f97c47d02fb056e225d745714fe", + "matched": true, + "mismatch_codes": [], + "missing_capabilities": [], + "requirements_digest": "sha256:b08b0b77a70c0a5fc274eaa8e4ef9e1ea4ab465844d27ea69f5249ab1e69541d", + "schema_version": "openadapt.capability-match/v1" + }, + "manifest": { + "agent_version": "0.15.0", + "architecture": "aarch64", + "expires_at": "2026-07-28T12:15:00Z", + "flow_version": "1.26.0", + "generated_at": "2026-07-28T12:00:00Z", + "host_os": "macos", + "installation_id": "install_reference_01", + "lanes": [ + { + "capabilities": [ + "application_identity", + "durable_resume", + "effect_tier_2", + "effect_verification", + "evidence_export", + "governed_authorization", + "local_secret_resolution", + "no_external_egress", + "ocr_relational_resolution", + "parameter_by_reference", + "physical_input_actuation", + "pixel_observation", + "postcondition_verification", + "record_identity", + "session_continuity", + "session_identity", + "settled_state_detection", + "workflow_state_identity" + ], + "execution_mode": "external", + "supported_profiles": [ + "regulated", + "standard" + ], + "surface": "citrix" + }, + { + "capabilities": [ + "application_identity", + "durable_resume", + "effect_tier_1", + "playwright_actuation", + "playwright_dom", + "settled_state_detection", + "structural_resolution", + "workflow_state_identity" + ], + "execution_mode": "in_session", + "supported_profiles": [ + "standard" + ], + "surface": "web" + } + ], + "runner_id": "runner_reference_01", + "schema_version": "openadapt.runner-capability-manifest/v1" + }, + "manifest_canonical_json": "{\"agent_version\":\"0.15.0\",\"architecture\":\"aarch64\",\"expires_at\":\"2026-07-28T12:15:00Z\",\"flow_version\":\"1.26.0\",\"generated_at\":\"2026-07-28T12:00:00Z\",\"host_os\":\"macos\",\"installation_id\":\"install_reference_01\",\"lanes\":[{\"capabilities\":[\"application_identity\",\"durable_resume\",\"effect_tier_2\",\"effect_verification\",\"evidence_export\",\"governed_authorization\",\"local_secret_resolution\",\"no_external_egress\",\"ocr_relational_resolution\",\"parameter_by_reference\",\"physical_input_actuation\",\"pixel_observation\",\"postcondition_verification\",\"record_identity\",\"session_continuity\",\"session_identity\",\"settled_state_detection\",\"workflow_state_identity\"],\"execution_mode\":\"external\",\"supported_profiles\":[\"regulated\",\"standard\"],\"surface\":\"citrix\"},{\"capabilities\":[\"application_identity\",\"durable_resume\",\"effect_tier_1\",\"playwright_actuation\",\"playwright_dom\",\"settled_state_detection\",\"structural_resolution\",\"workflow_state_identity\"],\"execution_mode\":\"in_session\",\"supported_profiles\":[\"standard\"],\"surface\":\"web\"}],\"runner_id\":\"runner_reference_01\",\"schema_version\":\"openadapt.runner-capability-manifest/v1\"}", + "manifest_digest": "sha256:acc05cdf064569d93df594df151b01e7d1740f97c47d02fb056e225d745714fe", + "match_at": "2026-07-28T12:05:00Z", + "requirements": { + "authorization_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "binding_digest": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "execution_mode": "external", + "maximum_runtime_version": "1.26.9", + "minimum_effect_tier": 2, + "minimum_runtime_version": "1.26.0", + "permitted_executor_ids": [], + "permitted_runner_ids": [ + "runner_reference_01" + ], + "plan_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "portable_intent_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "profile": "regulated", + "qualification_digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "required_capabilities": [ + "durable_resume", + "governed_authorization", + "local_secret_resolution", + "no_external_egress", + "ocr_relational_resolution", + "parameter_by_reference", + "pixel_observation", + "record_identity", + "settled_state_detection" + ], + "runtime_input_digest": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "schema_version": "openadapt.execution-requirements/v1", + "selected_plan_id": "plan_reference_01", + "surface": "citrix", + "workflow_family_id": "family_reference_01" + }, + "requirements_canonical_json": "{\"authorization_digest\":\"sha256:5555555555555555555555555555555555555555555555555555555555555555\",\"binding_digest\":\"sha256:4444444444444444444444444444444444444444444444444444444444444444\",\"execution_mode\":\"external\",\"maximum_runtime_version\":\"1.26.9\",\"minimum_effect_tier\":2,\"minimum_runtime_version\":\"1.26.0\",\"permitted_executor_ids\":[],\"permitted_runner_ids\":[\"runner_reference_01\"],\"plan_digest\":\"sha256:2222222222222222222222222222222222222222222222222222222222222222\",\"portable_intent_digest\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\"profile\":\"regulated\",\"qualification_digest\":\"sha256:3333333333333333333333333333333333333333333333333333333333333333\",\"required_capabilities\":[\"durable_resume\",\"governed_authorization\",\"local_secret_resolution\",\"no_external_egress\",\"ocr_relational_resolution\",\"parameter_by_reference\",\"pixel_observation\",\"record_identity\",\"settled_state_detection\"],\"runtime_input_digest\":\"sha256:6666666666666666666666666666666666666666666666666666666666666666\",\"schema_version\":\"openadapt.execution-requirements/v1\",\"selected_plan_id\":\"plan_reference_01\",\"surface\":\"citrix\",\"workflow_family_id\":\"family_reference_01\"}", + "requirements_digest": "sha256:b08b0b77a70c0a5fc274eaa8e4ef9e1ea4ab465844d27ea69f5249ab1e69541d", + "schema_version": "openadapt.runner-capability-match-vector/v1" +} diff --git a/scripts/export_control_overlay_schemas.py b/scripts/export_control_overlay_schemas.py index 49a7d12..072dc53 100644 --- a/scripts/export_control_overlay_schemas.py +++ b/scripts/export_control_overlay_schemas.py @@ -10,8 +10,11 @@ ControlOverlayFrameV2, ControlOverlayTimelineV1, ControlOverlayTimelineV2, + ExecutionRequirementsV1, HumanDecisionReceiptV1, HumanDecisionTaskV1, + RunnerCapabilityManifestV1, + match_runner_capabilities, ) ROOT = Path(__file__).resolve().parents[1] @@ -23,9 +26,118 @@ "control-overlay-timeline-v2.json": ControlOverlayTimelineV2, "human-decision-task-v1.json": HumanDecisionTaskV1, "human-decision-receipt-v1.json": HumanDecisionReceiptV1, + "runner-capability-manifest-v1.json": RunnerCapabilityManifestV1, + "execution-requirements-v1.json": ExecutionRequirementsV1, } +def canonical_match_vector() -> dict[str, object]: + """Return one fixed, PHI-free vector for non-Python implementations.""" + + manifest = RunnerCapabilityManifestV1.model_validate( + { + "schema_version": "openadapt.runner-capability-manifest/v1", + "installation_id": "install_reference_01", + "runner_id": "runner_reference_01", + "agent_version": "0.15.0", + "flow_version": "1.26.0", + "host_os": "macos", + "architecture": "aarch64", + "lanes": [ + { + "surface": "web", + "execution_mode": "in_session", + "capabilities": [ + "playwright_dom", + "structural_resolution", + "playwright_actuation", + "application_identity", + "workflow_state_identity", + "settled_state_detection", + "durable_resume", + "effect_tier_1", + ], + "supported_profiles": ["standard"], + }, + { + "surface": "citrix", + "execution_mode": "external", + "capabilities": [ + "pixel_observation", + "ocr_relational_resolution", + "physical_input_actuation", + "application_identity", + "session_identity", + "workflow_state_identity", + "record_identity", + "governed_authorization", + "settled_state_detection", + "session_continuity", + "durable_resume", + "postcondition_verification", + "evidence_export", + "effect_verification", + "effect_tier_2", + "local_secret_resolution", + "parameter_by_reference", + "no_external_egress", + ], + "supported_profiles": ["regulated", "standard"], + }, + ], + "generated_at": "2026-07-28T12:00:00Z", + "expires_at": "2026-07-28T12:15:00Z", + } + ) + requirements = ExecutionRequirementsV1.model_validate( + { + "schema_version": "openadapt.execution-requirements/v1", + "workflow_family_id": "family_reference_01", + "portable_intent_digest": "sha256:" + "1" * 64, + "selected_plan_id": "plan_reference_01", + "plan_digest": "sha256:" + "2" * 64, + "qualification_digest": "sha256:" + "3" * 64, + "binding_digest": "sha256:" + "4" * 64, + "surface": "citrix", + "execution_mode": "external", + "profile": "regulated", + "minimum_effect_tier": 2, + "required_capabilities": [ + "pixel_observation", + "ocr_relational_resolution", + "record_identity", + "governed_authorization", + "settled_state_detection", + "durable_resume", + "local_secret_resolution", + "parameter_by_reference", + "no_external_egress", + ], + "permitted_runner_ids": ["runner_reference_01"], + "minimum_runtime_version": "1.26.0", + "maximum_runtime_version": "1.26.9", + "authorization_digest": "sha256:" + "5" * 64, + "runtime_input_digest": "sha256:" + "6" * 64, + } + ) + match = match_runner_capabilities( + manifest, + requirements, + at="2026-07-28T12:05:00Z", + ) + return { + "schema_version": "openadapt.runner-capability-match-vector/v1", + "match_at": "2026-07-28T12:05:00Z", + "manifest": manifest.model_dump(mode="json"), + "manifest_canonical_json": manifest.canonical_bytes().decode("ascii"), + "manifest_digest": manifest.digest, + "requirements": requirements.model_dump(mode="json"), + "requirements_canonical_json": requirements.canonical_bytes().decode("ascii"), + "requirements_digest": requirements.digest, + "expected_match": match.model_dump(mode="json"), + } + + def rendered_schemas() -> dict[str, str]: return { filename: json.dumps(model.model_json_schema(), indent=2, sort_keys=True) + "\n" @@ -37,6 +149,10 @@ def main() -> int: SCHEMA_DIR.mkdir(parents=True, exist_ok=True) for filename, text in rendered_schemas().items(): (SCHEMA_DIR / filename).write_text(text, encoding="utf-8") + (SCHEMA_DIR / "runner-capability-match-v1.vector.json").write_text( + json.dumps(canonical_match_vector(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) return 0 diff --git a/tests/test_runner_capability.py b/tests/test_runner_capability.py new file mode 100644 index 0000000..cde5a87 --- /dev/null +++ b/tests/test_runner_capability.py @@ -0,0 +1,428 @@ +"""Durable contract tests for runner capability admission.""" + +from __future__ import annotations + +import json +from importlib.resources import files + +import pytest +from pydantic import ValidationError + +from openadapt_types import ( + CapabilityMatchV1, + CapabilityMismatchCode, + ExecutionRequirementsV1, + RunnerCapability, + RunnerCapabilityManifestV1, + match_runner_capabilities, +) + + +def _manifest_payload() -> dict[str, object]: + return { + "schema_version": "openadapt.runner-capability-manifest/v1", + "installation_id": "install_reference_01", + "runner_id": "runner_reference_01", + "agent_version": "0.15.0", + "flow_version": "1.26.0", + "host_os": "macos", + "architecture": "aarch64", + "lanes": [ + { + "surface": "web", + "execution_mode": "in_session", + "capabilities": [ + "playwright_dom", + "structural_resolution", + "playwright_actuation", + "effect_tier_1", + ], + "supported_profiles": ["standard"], + }, + { + "surface": "citrix", + "execution_mode": "external", + "capabilities": [ + "pixel_observation", + "ocr_relational_resolution", + "physical_input_actuation", + "record_identity", + "governed_authorization", + "settled_state_detection", + "durable_resume", + "effect_tier_2", + "local_secret_resolution", + "parameter_by_reference", + "no_external_egress", + ], + "supported_profiles": ["standard", "regulated"], + }, + ], + "generated_at": "2026-07-28T12:00:00Z", + "expires_at": "2026-07-28T12:15:00Z", + } + + +def _requirements_payload() -> dict[str, object]: + return { + "schema_version": "openadapt.execution-requirements/v1", + "workflow_family_id": "family_reference_01", + "portable_intent_digest": "sha256:" + "1" * 64, + "selected_plan_id": "plan_reference_01", + "plan_digest": "sha256:" + "2" * 64, + "qualification_digest": "sha256:" + "3" * 64, + "binding_digest": "sha256:" + "4" * 64, + "surface": "citrix", + "execution_mode": "external", + "profile": "regulated", + "minimum_effect_tier": 2, + "required_capabilities": [ + "pixel_observation", + "ocr_relational_resolution", + "record_identity", + "governed_authorization", + "settled_state_detection", + "durable_resume", + "local_secret_resolution", + "parameter_by_reference", + "no_external_egress", + ], + "permitted_runner_ids": ["runner_reference_01"], + "permitted_executor_ids": [], + "minimum_runtime_version": "1.26.0", + "maximum_runtime_version": "1.26.9", + "authorization_digest": "sha256:" + "5" * 64, + "runtime_input_digest": "sha256:" + "6" * 64, + } + + +def test_closed_models_reject_unknown_fields_and_capabilities() -> None: + manifest = _manifest_payload() + manifest["application"] = "must not enter the public interface" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + RunnerCapabilityManifestV1.model_validate(manifest) + + manifest = _manifest_payload() + manifest["lanes"][0]["capabilities"] = ["customer_specific_locator"] + with pytest.raises(ValidationError, match="Input should be"): + RunnerCapabilityManifestV1.model_validate(manifest) + + requirements = _requirements_payload() + requirements["required_capabilities"] = ["unknown_capability"] + with pytest.raises(ValidationError, match="Input should be"): + ExecutionRequirementsV1.model_validate(requirements) + + +def test_closed_vocabulary_contains_the_existing_desktop_capabilities() -> None: + existing_desktop_capabilities = { + "actuation", + "application_identity", + "effect_verification", + "governed_authorization", + "identity_verification", + "immediate_screen_confirmation", + "independent_session", + "independent_system_of_record", + "persisted_state_reacquisition", + "pixel_observation", + "playwright_dom", + "postcondition_verification", + "session_continuity", + "settled_state_detection", + "structural_observation", + "workflow_state_identity", + } + assert existing_desktop_capabilities <= { + capability.value for capability in RunnerCapability + } + + +@pytest.mark.parametrize( + ("field", "duplicate"), + [ + ("capabilities", "pixel_observation"), + ("supported_profiles", "regulated"), + ], +) +def test_lane_rejects_duplicate_set_members(field: str, duplicate: str) -> None: + payload = _manifest_payload() + lane = payload["lanes"][1] + values = list(lane[field]) + values.append(duplicate) + lane[field] = values + with pytest.raises(ValidationError): + RunnerCapabilityManifestV1.model_validate(payload) + + +def test_manifest_rejects_duplicate_surface_mode_lanes() -> None: + payload = _manifest_payload() + payload["lanes"].append(dict(payload["lanes"][0])) + with pytest.raises(ValidationError): + RunnerCapabilityManifestV1.model_validate(payload) + + +@pytest.mark.parametrize( + ("field", "duplicate"), + [ + ("required_capabilities", "pixel_observation"), + ("permitted_runner_ids", "runner_reference_01"), + ("permitted_executor_ids", "executor_reference_01"), + ], +) +def test_requirements_reject_duplicate_set_members(field: str, duplicate: str) -> None: + payload = _requirements_payload() + values = list(payload[field]) + if not values: + values.append(duplicate) + values.append(duplicate) + payload[field] = values + with pytest.raises(ValidationError): + ExecutionRequirementsV1.model_validate(payload) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("schema_version", "openadapt.runner-capability-manifest/v2"), + ("agent_version", "0.15"), + ("flow_version", "1.26.0rc1"), + ("flow_version", "01.26.0"), + ], +) +def test_manifest_rejects_invalid_contract_or_runtime_versions( + field: str, value: str +) -> None: + payload = _manifest_payload() + payload[field] = value + with pytest.raises(ValidationError): + RunnerCapabilityManifestV1.model_validate(payload) + + +def test_contract_versions_are_required_and_version_components_are_bounded() -> None: + payload = _manifest_payload() + del payload["schema_version"] + with pytest.raises(ValidationError, match="schema_version"): + RunnerCapabilityManifestV1.model_validate(payload) + + payload = _manifest_payload() + payload["flow_version"] = "1.1234567890.0" + with pytest.raises(ValidationError): + RunnerCapabilityManifestV1.model_validate(payload) + + requirements = _requirements_payload() + del requirements["schema_version"] + with pytest.raises(ValidationError, match="schema_version"): + ExecutionRequirementsV1.model_validate(requirements) + + +def test_requirements_reject_invalid_or_reversed_runtime_ranges() -> None: + for minimum, maximum in ( + ("1.26", "1.26.9"), + ("1.27.0", "1.26.9"), + ): + payload = _requirements_payload() + payload["minimum_runtime_version"] = minimum + payload["maximum_runtime_version"] = maximum + with pytest.raises(ValidationError): + ExecutionRequirementsV1.model_validate(payload) + + payload = _requirements_payload() + payload["minimum_effect_tier"] = "2" + with pytest.raises(ValidationError): + ExecutionRequirementsV1.model_validate(payload) + + +def test_manifest_rejects_an_invalid_lifetime() -> None: + payload = _manifest_payload() + payload["expires_at"] = payload["generated_at"] + with pytest.raises(ValidationError, match="later than generated_at"): + RunnerCapabilityManifestV1.model_validate(payload) + + payload = _manifest_payload() + payload["generated_at"] = "2026-07-28T12:00:00.1234567Z" + with pytest.raises(ValidationError): + RunnerCapabilityManifestV1.model_validate(payload) + + +def test_canonicalization_sorts_sets_and_produces_stable_digests() -> None: + first_payload = _manifest_payload() + second_payload = _manifest_payload() + second_payload["lanes"] = list(reversed(second_payload["lanes"])) + for lane in second_payload["lanes"]: + lane["capabilities"] = list(reversed(lane["capabilities"])) + + first = RunnerCapabilityManifestV1.model_validate(first_payload) + second = RunnerCapabilityManifestV1.model_validate(second_payload) + assert first.canonical_bytes() == second.canonical_bytes() + assert first.digest == second.digest + assert first.canonical_bytes().decode("ascii") + assert b", " not in first.canonical_bytes() + + +def test_matcher_accepts_an_exact_compatible_runner() -> None: + match = match_runner_capabilities( + RunnerCapabilityManifestV1.model_validate(_manifest_payload()), + ExecutionRequirementsV1.model_validate(_requirements_payload()), + at="2026-07-28T12:05:00Z", + ) + assert match.matched is True + assert match.mismatch_codes == () + assert match.missing_capabilities == () + + +def test_matcher_reports_expiry_surface_mode_profile_version_pin_and_capability() -> ( + None +): + manifest_payload = _manifest_payload() + manifest_payload.update( + { + "runner_id": None, + "flow_version": "1.25.9", + "lanes": [ + { + "surface": "citrix", + "execution_mode": "external", + "supported_profiles": ["demo"], + "capabilities": ["pixel_observation", "effect_tier_4"], + } + ], + } + ) + match = match_runner_capabilities( + RunnerCapabilityManifestV1.model_validate(manifest_payload), + ExecutionRequirementsV1.model_validate(_requirements_payload()), + at="2026-07-28T12:15:00Z", + ) + + assert match.matched is False + assert set(match.mismatch_codes) == { + CapabilityMismatchCode.MANIFEST_EXPIRED, + CapabilityMismatchCode.PROFILE_UNSUPPORTED, + CapabilityMismatchCode.RUNTIME_VERSION_BELOW_MINIMUM, + CapabilityMismatchCode.RUNNER_ID_UNASSIGNED, + CapabilityMismatchCode.MINIMUM_EFFECT_TIER_UNSUPPORTED, + CapabilityMismatchCode.REQUIRED_CAPABILITY_MISSING, + } + assert RunnerCapability.OCR_RELATIONAL_RESOLUTION in match.missing_capabilities + + +def test_matcher_rejects_an_unsupported_surface_or_mode() -> None: + manifest = RunnerCapabilityManifestV1.model_validate(_manifest_payload()) + requirements_payload = _requirements_payload() + requirements_payload["surface"] = "rdp" + surface_match = match_runner_capabilities( + manifest, + ExecutionRequirementsV1.model_validate(requirements_payload), + at="2026-07-28T12:05:00Z", + ) + assert surface_match.mismatch_codes == (CapabilityMismatchCode.SURFACE_UNSUPPORTED,) + + requirements_payload = _requirements_payload() + requirements_payload["execution_mode"] = "in_session" + mode_match = match_runner_capabilities( + manifest, + ExecutionRequirementsV1.model_validate(requirements_payload), + at="2026-07-28T12:05:00Z", + ) + assert mode_match.mismatch_codes == ( + CapabilityMismatchCode.EXECUTION_MODE_UNSUPPORTED, + ) + + +def test_matcher_never_borrows_capabilities_or_effect_tiers_between_lanes() -> None: + requirements_payload = _requirements_payload() + requirements_payload["minimum_effect_tier"] = 1 + requirements_payload["required_capabilities"] = ["playwright_dom"] + match = match_runner_capabilities( + RunnerCapabilityManifestV1.model_validate(_manifest_payload()), + ExecutionRequirementsV1.model_validate(requirements_payload), + at="2026-07-28T12:05:00Z", + ) + assert match.matched is False + assert set(match.mismatch_codes) == { + CapabilityMismatchCode.MINIMUM_EFFECT_TIER_UNSUPPORTED, + CapabilityMismatchCode.REQUIRED_CAPABILITY_MISSING, + } + assert match.missing_capabilities == (RunnerCapability.PLAYWRIGHT_DOM,) + + +def test_matcher_rejects_runtime_above_the_qualified_maximum() -> None: + manifest_payload = _manifest_payload() + manifest_payload["flow_version"] = "1.27.0" + match = match_runner_capabilities( + RunnerCapabilityManifestV1.model_validate(manifest_payload), + ExecutionRequirementsV1.model_validate(_requirements_payload()), + at="2026-07-28T12:05:00Z", + ) + assert match.matched is False + assert match.mismatch_codes == ( + CapabilityMismatchCode.RUNTIME_VERSION_ABOVE_MAXIMUM, + ) + + +def test_matcher_rejects_a_manifest_before_its_generation_instant() -> None: + match = match_runner_capabilities( + RunnerCapabilityManifestV1.model_validate(_manifest_payload()), + ExecutionRequirementsV1.model_validate(_requirements_payload()), + at="2026-07-28T11:59:59Z", + ) + assert match.matched is False + assert match.mismatch_codes == (CapabilityMismatchCode.MANIFEST_NOT_YET_VALID,) + + +def test_runner_matcher_fails_closed_on_a_pinned_external_executor() -> None: + requirements_payload = _requirements_payload() + requirements_payload["permitted_executor_ids"] = ["executor_reference_01"] + match = match_runner_capabilities( + RunnerCapabilityManifestV1.model_validate(_manifest_payload()), + ExecutionRequirementsV1.model_validate(requirements_payload), + at="2026-07-28T12:05:00Z", + ) + assert match.matched is False + assert match.mismatch_codes == ( + CapabilityMismatchCode.EXECUTOR_ID_REQUIREMENT_UNSUPPORTED, + ) + + +def test_match_result_cannot_claim_success_with_a_mismatch() -> None: + with pytest.raises(ValidationError, match="matched must be true"): + CapabilityMatchV1( + schema_version="openadapt.capability-match/v1", + manifest_digest="sha256:" + "a" * 64, + requirements_digest="sha256:" + "b" * 64, + matched=True, + mismatch_codes=(CapabilityMismatchCode.MANIFEST_EXPIRED,), + ) + + +def test_packaged_schemas_and_canonical_match_vector_are_exact() -> None: + packaged = files("openadapt_types.schemas") + assert ( + json.loads(packaged.joinpath("runner-capability-manifest-v1.json").read_text()) + == RunnerCapabilityManifestV1.model_json_schema() + ) + assert ( + json.loads(packaged.joinpath("execution-requirements-v1.json").read_text()) + == ExecutionRequirementsV1.model_json_schema() + ) + + vector = json.loads( + packaged.joinpath("runner-capability-match-v1.vector.json").read_text() + ) + manifest = RunnerCapabilityManifestV1.model_validate(vector["manifest"]) + requirements = ExecutionRequirementsV1.model_validate(vector["requirements"]) + match = match_runner_capabilities( + manifest, + requirements, + at=vector["match_at"], + ) + assert ( + manifest.canonical_bytes().decode("ascii") == vector["manifest_canonical_json"] + ) + assert ( + requirements.canonical_bytes().decode("ascii") + == vector["requirements_canonical_json"] + ) + assert manifest.digest == vector["manifest_digest"] + assert requirements.digest == vector["requirements_digest"] + assert match.model_dump(mode="json") == vector["expected_match"] From 95af1836b08ce26d961039b6b9ae1465d7c973b1 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 06:47:58 -0400 Subject: [PATCH 2/2] test: add negative runner capability vectors --- .../runner-capability-negative-v1.vector.json | 214 ++++++++++++++++++ scripts/export_control_overlay_schemas.py | 50 ++++ tests/test_runner_capability.py | 25 ++ 3 files changed, 289 insertions(+) create mode 100644 openadapt_types/schemas/runner-capability-negative-v1.vector.json diff --git a/openadapt_types/schemas/runner-capability-negative-v1.vector.json b/openadapt_types/schemas/runner-capability-negative-v1.vector.json new file mode 100644 index 0000000..f26eee1 --- /dev/null +++ b/openadapt_types/schemas/runner-capability-negative-v1.vector.json @@ -0,0 +1,214 @@ +{ + "cases": [ + { + "case_id": "duplicate_surface_mode_lane", + "expected_manifest_accepted": false, + "expected_match": null, + "expected_rejection": "duplicate_surface_mode_lane", + "manifest": { + "agent_version": "0.15.0", + "architecture": "aarch64", + "expires_at": "2026-07-28T12:15:00Z", + "flow_version": "1.26.0", + "generated_at": "2026-07-28T12:00:00Z", + "host_os": "macos", + "installation_id": "install_reference_01", + "lanes": [ + { + "capabilities": [ + "application_identity", + "durable_resume", + "effect_tier_2", + "effect_verification", + "evidence_export", + "governed_authorization", + "local_secret_resolution", + "no_external_egress", + "ocr_relational_resolution", + "parameter_by_reference", + "physical_input_actuation", + "pixel_observation", + "postcondition_verification", + "record_identity", + "session_continuity", + "session_identity", + "settled_state_detection", + "workflow_state_identity" + ], + "execution_mode": "external", + "supported_profiles": [ + "regulated", + "standard" + ], + "surface": "citrix" + }, + { + "capabilities": [ + "application_identity", + "durable_resume", + "effect_tier_1", + "playwright_actuation", + "playwright_dom", + "settled_state_detection", + "structural_resolution", + "workflow_state_identity" + ], + "execution_mode": "in_session", + "supported_profiles": [ + "standard" + ], + "surface": "web" + }, + { + "capabilities": [ + "pixel_observation" + ], + "execution_mode": "external", + "supported_profiles": [ + "regulated", + "standard" + ], + "surface": "citrix" + } + ], + "runner_id": "runner_reference_01", + "schema_version": "openadapt.runner-capability-manifest/v1" + }, + "match_at": "2026-07-28T12:05:00Z", + "requirements": { + "authorization_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "binding_digest": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "execution_mode": "external", + "maximum_runtime_version": "1.26.9", + "minimum_effect_tier": 2, + "minimum_runtime_version": "1.26.0", + "permitted_executor_ids": [], + "permitted_runner_ids": [ + "runner_reference_01" + ], + "plan_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "portable_intent_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "profile": "regulated", + "qualification_digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "required_capabilities": [ + "durable_resume", + "governed_authorization", + "local_secret_resolution", + "no_external_egress", + "ocr_relational_resolution", + "parameter_by_reference", + "pixel_observation", + "record_identity", + "settled_state_detection" + ], + "runtime_input_digest": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "schema_version": "openadapt.execution-requirements/v1", + "selected_plan_id": "plan_reference_01", + "surface": "citrix", + "workflow_family_id": "family_reference_01" + } + }, + { + "case_id": "cross_lane_capability_and_effect_borrowing", + "expected_manifest_accepted": true, + "expected_match": { + "manifest_digest": "sha256:acc05cdf064569d93df594df151b01e7d1740f97c47d02fb056e225d745714fe", + "matched": false, + "mismatch_codes": [ + "minimum_effect_tier_unsupported", + "required_capability_missing" + ], + "missing_capabilities": [ + "playwright_dom" + ], + "requirements_digest": "sha256:2b4239774b05478d93b2cddd20d9f6419b132715642686a67f77f5c9d1ff48f0", + "schema_version": "openadapt.capability-match/v1" + }, + "expected_rejection": null, + "manifest": { + "agent_version": "0.15.0", + "architecture": "aarch64", + "expires_at": "2026-07-28T12:15:00Z", + "flow_version": "1.26.0", + "generated_at": "2026-07-28T12:00:00Z", + "host_os": "macos", + "installation_id": "install_reference_01", + "lanes": [ + { + "capabilities": [ + "application_identity", + "durable_resume", + "effect_tier_2", + "effect_verification", + "evidence_export", + "governed_authorization", + "local_secret_resolution", + "no_external_egress", + "ocr_relational_resolution", + "parameter_by_reference", + "physical_input_actuation", + "pixel_observation", + "postcondition_verification", + "record_identity", + "session_continuity", + "session_identity", + "settled_state_detection", + "workflow_state_identity" + ], + "execution_mode": "external", + "supported_profiles": [ + "regulated", + "standard" + ], + "surface": "citrix" + }, + { + "capabilities": [ + "application_identity", + "durable_resume", + "effect_tier_1", + "playwright_actuation", + "playwright_dom", + "settled_state_detection", + "structural_resolution", + "workflow_state_identity" + ], + "execution_mode": "in_session", + "supported_profiles": [ + "standard" + ], + "surface": "web" + } + ], + "runner_id": "runner_reference_01", + "schema_version": "openadapt.runner-capability-manifest/v1" + }, + "match_at": "2026-07-28T12:05:00Z", + "requirements": { + "authorization_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "binding_digest": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "execution_mode": "external", + "maximum_runtime_version": "1.26.9", + "minimum_effect_tier": 1, + "minimum_runtime_version": "1.26.0", + "permitted_executor_ids": [], + "permitted_runner_ids": [ + "runner_reference_01" + ], + "plan_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "portable_intent_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "profile": "regulated", + "qualification_digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "required_capabilities": [ + "playwright_dom" + ], + "runtime_input_digest": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "schema_version": "openadapt.execution-requirements/v1", + "selected_plan_id": "plan_reference_01", + "surface": "citrix", + "workflow_family_id": "family_reference_01" + } + } + ], + "schema_version": "openadapt.runner-capability-negative-vectors/v1" +} diff --git a/scripts/export_control_overlay_schemas.py b/scripts/export_control_overlay_schemas.py index 072dc53..749274b 100644 --- a/scripts/export_control_overlay_schemas.py +++ b/scripts/export_control_overlay_schemas.py @@ -3,7 +3,9 @@ from __future__ import annotations import json +from copy import deepcopy from pathlib import Path +from typing import Any, cast from openadapt_types import ( ControlOverlayFrameV1, @@ -138,6 +140,50 @@ def canonical_match_vector() -> dict[str, object]: } +def negative_match_vectors() -> dict[str, object]: + """Return fail-closed vectors for non-Python manifest consumers.""" + + reference = cast(dict[str, Any], canonical_match_vector()) + duplicate_manifest = deepcopy(reference["manifest"]) + duplicate_lane = deepcopy(duplicate_manifest["lanes"][0]) + duplicate_lane["capabilities"] = ["pixel_observation"] + duplicate_manifest["lanes"].append(duplicate_lane) + + borrowing_manifest = deepcopy(reference["manifest"]) + borrowing_requirements = deepcopy(reference["requirements"]) + borrowing_requirements["minimum_effect_tier"] = 1 + borrowing_requirements["required_capabilities"] = ["playwright_dom"] + borrowing_match = match_runner_capabilities( + RunnerCapabilityManifestV1.model_validate(borrowing_manifest), + ExecutionRequirementsV1.model_validate(borrowing_requirements), + at=reference["match_at"], + ) + + return { + "schema_version": "openadapt.runner-capability-negative-vectors/v1", + "cases": [ + { + "case_id": "duplicate_surface_mode_lane", + "manifest": duplicate_manifest, + "requirements": reference["requirements"], + "match_at": reference["match_at"], + "expected_manifest_accepted": False, + "expected_rejection": "duplicate_surface_mode_lane", + "expected_match": None, + }, + { + "case_id": "cross_lane_capability_and_effect_borrowing", + "manifest": borrowing_manifest, + "requirements": borrowing_requirements, + "match_at": reference["match_at"], + "expected_manifest_accepted": True, + "expected_rejection": None, + "expected_match": borrowing_match.model_dump(mode="json"), + }, + ], + } + + def rendered_schemas() -> dict[str, str]: return { filename: json.dumps(model.model_json_schema(), indent=2, sort_keys=True) + "\n" @@ -153,6 +199,10 @@ def main() -> int: json.dumps(canonical_match_vector(), indent=2, sort_keys=True) + "\n", encoding="utf-8", ) + (SCHEMA_DIR / "runner-capability-negative-v1.vector.json").write_text( + json.dumps(negative_match_vectors(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) return 0 diff --git a/tests/test_runner_capability.py b/tests/test_runner_capability.py index cde5a87..c19c6f4 100644 --- a/tests/test_runner_capability.py +++ b/tests/test_runner_capability.py @@ -426,3 +426,28 @@ def test_packaged_schemas_and_canonical_match_vector_are_exact() -> None: assert manifest.digest == vector["manifest_digest"] assert requirements.digest == vector["requirements_digest"] assert match.model_dump(mode="json") == vector["expected_match"] + + +def test_packaged_negative_vectors_reject_duplicate_and_cross_lane_borrowing() -> None: + packaged = files("openadapt_types.schemas") + vector = json.loads( + packaged.joinpath("runner-capability-negative-v1.vector.json").read_text() + ) + cases = {case["case_id"]: case for case in vector["cases"]} + + duplicate = cases["duplicate_surface_mode_lane"] + with pytest.raises(ValidationError, match="unique surface and execution-mode"): + RunnerCapabilityManifestV1.model_validate(duplicate["manifest"]) + assert duplicate["expected_manifest_accepted"] is False + assert duplicate["expected_rejection"] == "duplicate_surface_mode_lane" + assert duplicate["expected_match"] is None + + borrowing = cases["cross_lane_capability_and_effect_borrowing"] + match = match_runner_capabilities( + RunnerCapabilityManifestV1.model_validate(borrowing["manifest"]), + ExecutionRequirementsV1.model_validate(borrowing["requirements"]), + at=borrowing["match_at"], + ) + assert borrowing["expected_manifest_accepted"] is True + assert borrowing["expected_rejection"] is None + assert match.model_dump(mode="json") == borrowing["expected_match"]