From 93c1957b408b24018d67c2437132f645131fd895 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 8 Aug 2026 18:15:32 +0200 Subject: [PATCH 1/3] feat: add portable business decision contracts --- README.md | 10 + openadapt_types/__init__.py | 51 +- openadapt_types/business_decision.py | 654 ++++++++++++++++++ .../business-decision-answer-receipt-v1.json | 128 ++++ .../schemas/business-decision-answer-v1.json | 125 ++++ .../business-decision-delivery-policy-v1.json | 162 +++++ .../business-decision-presentation-v1.json | 98 +++ .../schemas/business-decision-task-v1.json | 317 +++++++++ scripts/export_control_overlay_schemas.py | 10 + tests/test_business_decision.py | 418 +++++++++++ 10 files changed, 1971 insertions(+), 2 deletions(-) create mode 100644 openadapt_types/business_decision.py create mode 100644 openadapt_types/schemas/business-decision-answer-receipt-v1.json create mode 100644 openadapt_types/schemas/business-decision-answer-v1.json create mode 100644 openadapt_types/schemas/business-decision-delivery-policy-v1.json create mode 100644 openadapt_types/schemas/business-decision-presentation-v1.json create mode 100644 openadapt_types/schemas/business-decision-task-v1.json create mode 100644 tests/test_business_decision.py diff --git a/README.md b/README.md index 94f7cb8..6f7f9f6 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Documentation for the whole stack lives at | `ExecuteRequestV1` / `ExecuteStatusV1` | Async qualified-execution request and lifecycle contracts | | `ExecuteEvidenceReceiptV1` | Outcome receipt with contract proof and evidence identifiers | | `EffectStrengthV1` | Named effect-proof strength for consequential execution | +| `BusinessDecisionTaskV1` / `BusinessDecisionAnswerV1` | Signed, finite business choices for authenticated mobile or local operator routes | +| `BusinessDecisionAnswerReceiptV1` | Runner answer receipt that cannot claim a verified business effect | ## Quick start @@ -204,6 +206,14 @@ status, terminal evidence receipts, and signed decision and terminal webhooks. It keeps `waiting_for_reconciliation` as a lifecycle state and `reconciliation_required` as a terminal outcome. +The separate `business-decision-*-v1.json` files define a finite human branch +that the workflow declared before execution. They do not reuse the operational +halt actions. They carry only opaque bindings, option IDs, digests, counts, and +closed status values. The reviewed question and option text live in a signed +presentation artifact, while screenshots and live record values stay on the +customer runner. An accepted answer only selects a compiled branch. The next +action must still pass its live-state, identity, policy, and effect contracts. + ## OpenAdapt Execute v1 OpenAdapt Execute is the public asynchronous contract for a qualified diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index c2a44fd..c6c38da 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -34,6 +34,30 @@ BenchmarkObservation, BenchmarkTask, ) +from openadapt_types.business_decision import ( + BUSINESS_DECISION_ANSWER_RECEIPT_REASONS, + BUSINESS_DECISION_ANSWER_RECEIPT_SCHEMA, + BUSINESS_DECISION_ANSWER_SCHEMA, + BUSINESS_DECISION_DELIVERY_POLICY_SCHEMA, + BUSINESS_DECISION_PRESENTATION_SCHEMA, + BUSINESS_DECISION_TASK_SCHEMA, + BusinessDecisionAnswerReceiptReason, + BusinessDecisionAnswerReceiptState, + BusinessDecisionAnswerReceiptV1, + BusinessDecisionAnswerV1, + BusinessDecisionDeliveryMode, + BusinessDecisionDeliveryPolicyV1, + BusinessDecisionOptionBindingV1, + BusinessDecisionPresentationOptionV1, + BusinessDecisionPresentationV1, + BusinessDecisionRequiredAuthn, + BusinessDecisionTaskV1, + sign_business_decision_answer_hmac, + sign_business_decision_answer_receipt_hmac, + sign_business_decision_delivery_policy_hmac, + sign_business_decision_task_hmac, + validate_business_decision_answer, +) from openadapt_types.computer_state import ( BoundingBox, ComputerState, @@ -104,8 +128,8 @@ ExecuteWebhookV1, sign_execute_webhook_hmac, ) -from openadapt_types.execute_openapi import execute_openapi_document from openadapt_types.execute_client import ExecuteApiError, ExecuteClient +from openadapt_types.execute_openapi import execute_openapi_document from openadapt_types.execution_requirements import ( CAPABILITY_MATCH_SCHEMA, EXECUTION_REQUIREMENTS_SCHEMA, @@ -125,9 +149,9 @@ HumanDecisionDeliveryState, HumanDecisionEntityFallback, HumanDecisionEvidenceSummaryV1, + HumanDecisionQualifiedEntityV1, HumanDecisionQuestionTemplate, HumanDecisionQuestionV1, - HumanDecisionQualifiedEntityV1, HumanDecisionReceiptReason, HumanDecisionReceiptState, HumanDecisionReceiptV1, @@ -225,6 +249,29 @@ "BenchmarkAgent", "BenchmarkObservation", "BenchmarkTask", + # portable business decisions + "BUSINESS_DECISION_ANSWER_SCHEMA", + "BUSINESS_DECISION_ANSWER_RECEIPT_REASONS", + "BUSINESS_DECISION_ANSWER_RECEIPT_SCHEMA", + "BUSINESS_DECISION_DELIVERY_POLICY_SCHEMA", + "BUSINESS_DECISION_PRESENTATION_SCHEMA", + "BUSINESS_DECISION_TASK_SCHEMA", + "BusinessDecisionAnswerV1", + "BusinessDecisionDeliveryPolicyV1", + "BusinessDecisionDeliveryMode", + "BusinessDecisionOptionBindingV1", + "BusinessDecisionPresentationOptionV1", + "BusinessDecisionPresentationV1", + "BusinessDecisionAnswerReceiptReason", + "BusinessDecisionAnswerReceiptState", + "BusinessDecisionAnswerReceiptV1", + "BusinessDecisionRequiredAuthn", + "BusinessDecisionTaskV1", + "sign_business_decision_answer_hmac", + "sign_business_decision_answer_receipt_hmac", + "sign_business_decision_delivery_policy_hmac", + "sign_business_decision_task_hmac", + "validate_business_decision_answer", # episode "Episode", "Step", diff --git a/openadapt_types/business_decision.py b/openadapt_types/business_decision.py new file mode 100644 index 0000000..be22041 --- /dev/null +++ b/openadapt_types/business_decision.py @@ -0,0 +1,654 @@ +"""Portable, privacy-safe contracts for finite business decisions. + +This family is separate from :mod:`openadapt_types.human_decision`. +``HumanDecisionTaskV2`` resolves an operational runtime halt. A +``BusinessDecisionTaskV1`` presents one finite branch that was declared and +qualified before the run started. It never grants Continue, Skip, Teach, or +Reconcile authority. + +The portable task is a presentation and authentication projection. It is not +execution authority. The customer runner must authenticate the task, submit +the selected option through Flow's business-decision store, reacquire the live +application state, and pass the successor action's normal identity and effect +contracts before it actuates. + +No question text, option label, screenshot, OCR output, record value, or live +identifier belongs in this wire format. A consumer renders reviewed static +copy from the exact ``presentation_digest``. A decision that needs protected +context is ``local_answer_required`` and cannot be answered by a remote client. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from collections.abc import Mapping +from datetime import datetime +from enum import Enum +from typing import Annotated, Any, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictInt, + StrictStr, + model_validator, +) + +BUSINESS_DECISION_TASK_SCHEMA = "openadapt.business-decision-task/v1" +BUSINESS_DECISION_ANSWER_SCHEMA = "openadapt.business-decision-answer/v1" +BUSINESS_DECISION_PRESENTATION_SCHEMA = "openadapt.business-decision-presentation/v1" +BUSINESS_DECISION_DELIVERY_POLICY_SCHEMA = ( + "openadapt.business-decision-delivery-policy/v1" +) +BUSINESS_DECISION_ANSWER_RECEIPT_SCHEMA = "openadapt.business-decision-answer-receipt/v1" + +_TASK_DOMAIN = b"openadapt.business-decision-task/v1\x00" +_ANSWER_DOMAIN = b"openadapt.business-decision-answer/v1\x00" +_DELIVERY_POLICY_DOMAIN = b"openadapt.business-decision-delivery-policy/v1\x00" +_ANSWER_RECEIPT_DOMAIN = b"openadapt.business-decision-answer-receipt/v1\x00" +_OPAQUE_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$" +_OPTION_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" +_IDEMPOTENCY_KEY_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,199}$" +_SHA256_PATTERN = r"^sha256:[0-9a-f]{64}$" +_SIGNATURE_PATTERN = r"^hmac-sha256:[0-9a-f]{64}$" +_ROLE_MAPPING_DIGEST_PATTERN = _SIGNATURE_PATTERN +_TIMESTAMP_PATTERN = ( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}:\d{2})$" +) +_OpaqueRoleRef = Annotated[StrictStr, Field(pattern=_OPAQUE_ID_PATTERN)] +_STATIC_PRESENTATION_TEXT_PATTERN = r"^[^\x00-\x1f\x7f]{1,500}$" + + +class _StrictContract(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class BusinessDecisionDeliveryMode(str, Enum): + """Whether the decision can leave the customer-controlled context.""" + + REMOTE_ANSWERABLE = "remote_answerable" + LOCAL_ANSWER_REQUIRED = "local_answer_required" + + +class BusinessDecisionRequiredAuthn(str, Enum): + """The exact authentication profile required by the qualified contract.""" + + LOCAL_ENTERPRISE_IDENTITY = "local_enterprise_identity" + AAL2 = "aal2" + WEBAUTHN = "webauthn" + + +class BusinessDecisionOptionBindingV1(_StrictContract): + """One finite option and its opaque compiled-successor commitment.""" + + option_id: StrictStr = Field(pattern=_OPTION_ID_PATTERN) + target_binding_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + + +class BusinessDecisionPresentationOptionV1(_StrictContract): + """One exact option label from the reviewed workflow contract.""" + + option_id: StrictStr = Field(pattern=_OPTION_ID_PATTERN) + label: StrictStr = Field( + min_length=1, + max_length=120, + pattern=_STATIC_PRESENTATION_TEXT_PATTERN, + ) + + +class BusinessDecisionPresentationV1(_StrictContract): + """Reviewed static copy for one exact qualified business decision. + + This artifact is separate from the Cloud-safe task because it contains + human-readable text. The qualification path must review it before remote + use. The signed task carries only this artifact's digest and opaque ref. + """ + + schema_version: Literal["openadapt.business-decision-presentation/v1"] = ( + BUSINESS_DECISION_PRESENTATION_SCHEMA + ) + presentation_ref: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + presentation_revision: StrictInt = Field(ge=1, le=2_147_483_647) + decision_contract_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + decision_contract_revision: StrictInt = Field(ge=1, le=2_147_483_647) + question: StrictStr = Field( + min_length=1, + max_length=500, + pattern=_STATIC_PRESENTATION_TEXT_PATTERN, + ) + options: tuple[BusinessDecisionPresentationOptionV1, ...] = Field( + min_length=2, + max_length=32, + ) + review_contract_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + reviewed_safe_for_remote: Literal[True] = True + + @model_validator(mode="after") + def _validate_presentation(self) -> BusinessDecisionPresentationV1: + if self.question.strip() != self.question: + raise ValueError("business decision question must be trimmed") + option_ids = tuple(option.option_id for option in self.options) + labels = tuple(option.label for option in self.options) + if len(option_ids) != len(set(option_ids)): + raise ValueError("business decision presentation option ids must be unique") + if any(label.strip() != label for label in labels): + raise ValueError("business decision presentation labels must be trimmed") + if len({label.casefold() for label in labels}) != len(labels): + raise ValueError("business decision presentation labels must be unique") + return self + + def canonical_bytes(self) -> bytes: + return _canonical_json(self.model_dump(mode="json")) + + @property + def digest(self) -> str: + return _digest(self.canonical_bytes()) + + +class BusinessDecisionDeliveryPolicyV1(_StrictContract): + """Signed qualification policy for local or remote decision delivery.""" + + schema_version: Literal[ + "openadapt.business-decision-delivery-policy/v1" + ] = BUSINESS_DECISION_DELIVERY_POLICY_SCHEMA + policy_ref: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + policy_revision: StrictInt = Field(ge=1, le=2_147_483_647) + decision_contract_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + decision_contract_revision: StrictInt = Field(ge=1, le=2_147_483_647) + presentation_ref: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + presentation_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + authorized_role_refs: tuple[_OpaqueRoleRef, ...] = Field( + min_length=1, max_length=16 + ) + authorized_route_refs: tuple[_OpaqueRoleRef, ...] = Field( + min_length=1, max_length=16 + ) + authorized_answer_issuer_key_ids: tuple[_OpaqueRoleRef, ...] = Field( + min_length=1, max_length=16 + ) + role_mapping_digest: StrictStr = Field(pattern=_ROLE_MAPPING_DIGEST_PATTERN) + required_authn: BusinessDecisionRequiredAuthn + delivery_mode: BusinessDecisionDeliveryMode + relay_capability_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + created_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 + ) + issuer_key_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + signature_algorithm: Literal["hmac-sha256"] = "hmac-sha256" + signature: StrictStr = Field(pattern=_SIGNATURE_PATTERN) + + @model_validator(mode="after") + def _validate_policy(self) -> BusinessDecisionDeliveryPolicyV1: + if len(self.authorized_role_refs) != len(set(self.authorized_role_refs)): + raise ValueError("business decision policy role refs must be unique") + if len(self.authorized_route_refs) != len(set(self.authorized_route_refs)): + raise ValueError("business decision policy route refs must be unique") + if len(self.authorized_answer_issuer_key_ids) != len( + set(self.authorized_answer_issuer_key_ids) + ): + raise ValueError("business decision policy answer key ids must be unique") + if _parse_timestamp(self.expires_at, "expires_at") <= _parse_timestamp( + self.created_at, "created_at" + ): + raise ValueError("expires_at must be after created_at") + return self + + def unsigned_payload(self) -> dict[str, Any]: + return self.model_dump(mode="json", exclude={"signature"}) + + def canonical_unsigned_bytes(self) -> bytes: + return _canonical_json(self.unsigned_payload()) + + @property + def digest(self) -> str: + return _digest(self.canonical_unsigned_bytes()) + + def verify_hmac(self, key: bytes) -> bool: + return _verify_hmac( + key, + self.unsigned_payload(), + self.signature, + _DELIVERY_POLICY_DOMAIN, + ) + + +class BusinessDecisionTaskV1(_StrictContract): + """A signed remote-safe projection of one exact business-decision pause.""" + + schema_version: Literal["openadapt.business-decision-task/v1"] = ( + BUSINESS_DECISION_TASK_SCHEMA + ) + task_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + task_revision: StrictInt = Field(default=1, ge=1, le=2_147_483_647) + tenant_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + runner_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + run_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + pause_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + pause_binding_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + request_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + request_revision: StrictInt = Field(default=1, ge=1, le=2_147_483_647) + request_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + supersedes_request_digest: StrictStr | None = Field( + default=None, pattern=_SHA256_PATTERN + ) + bundle_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + workflow_contract_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + governed_runtime_inputs_digest: StrictStr | None = Field( + default=None, pattern=_SHA256_PATTERN + ) + decision_contract_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + decision_contract_revision: StrictInt = Field(ge=1, le=2_147_483_647) + delivery_policy_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + program_scope_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + control_frames_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + presentation_ref: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + presentation_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + options: tuple[BusinessDecisionOptionBindingV1, ...] = Field( + min_length=2, + max_length=32, + ) + authorized_role_refs: tuple[_OpaqueRoleRef, ...] = Field( + min_length=1, max_length=16 + ) + authorized_route_refs: tuple[_OpaqueRoleRef, ...] = Field( + min_length=1, max_length=16 + ) + authorized_answer_issuer_key_ids: tuple[_OpaqueRoleRef, ...] = Field( + min_length=1, max_length=16 + ) + role_mapping_digest: StrictStr = Field(pattern=_ROLE_MAPPING_DIGEST_PATTERN) + required_authn: BusinessDecisionRequiredAuthn + delivery_mode: BusinessDecisionDeliveryMode + local_evidence_required: StrictBool + required_evidence_count: StrictInt = Field(default=0, ge=0, le=64) + relay_capability_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + idempotency_scope_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + created_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 + ) + issuer_key_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + signature_algorithm: Literal["hmac-sha256"] = "hmac-sha256" + signature: StrictStr = Field(pattern=_SIGNATURE_PATTERN) + + @model_validator(mode="after") + def _validate_task(self) -> BusinessDecisionTaskV1: + option_ids = tuple(option.option_id for option in self.options) + if len(option_ids) != len(set(option_ids)): + raise ValueError("business decision option ids must be unique") + if len(self.authorized_role_refs) != len(set(self.authorized_role_refs)): + raise ValueError("business decision role refs must be unique") + if len(self.authorized_route_refs) != len(set(self.authorized_route_refs)): + raise ValueError("business decision route refs must be unique") + if len(self.authorized_answer_issuer_key_ids) != len( + set(self.authorized_answer_issuer_key_ids) + ): + raise ValueError("business decision answer key ids must be unique") + created_at = _parse_timestamp(self.created_at, "created_at") + expires_at = _parse_timestamp(self.expires_at, "expires_at") + if expires_at <= created_at: + raise ValueError("expires_at must be after created_at") + if self.required_evidence_count > 0 and not self.local_evidence_required: + raise ValueError( + "required evidence count requires local_evidence_required=true" + ) + if ( + self.delivery_mode is BusinessDecisionDeliveryMode.REMOTE_ANSWERABLE + and self.local_evidence_required + ): + raise ValueError( + "a remote-answerable decision cannot require protected local evidence" + ) + if self.request_revision == 1 and self.supersedes_request_digest is not None: + raise ValueError("the first request revision cannot supersede a request") + if self.request_revision > 1 and self.supersedes_request_digest is None: + raise ValueError("a renewed request must bind its predecessor") + return self + + def unsigned_payload(self) -> dict[str, Any]: + return self.model_dump(mode="json", exclude={"signature"}) + + def canonical_unsigned_bytes(self) -> bytes: + return _canonical_json(self.unsigned_payload()) + + @property + def digest(self) -> str: + return _digest(self.canonical_unsigned_bytes()) + + def verify_hmac(self, key: bytes) -> bool: + return _verify_hmac(key, self.unsigned_payload(), self.signature, _TASK_DOMAIN) + + +class BusinessDecisionAnswerV1(_StrictContract): + """An authenticated route's signed relay of one finite mobile answer. + + The client chooses only ``option_id`` and supplies one idempotency key. The + authenticated route, not the client, must populate the principal, role, and + authentication-context references before it signs this contract. + """ + + schema_version: Literal["openadapt.business-decision-answer/v1"] = ( + BUSINESS_DECISION_ANSWER_SCHEMA + ) + task_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + task_revision: StrictInt = Field(ge=1, le=2_147_483_647) + task_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + request_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + option_id: StrictStr = Field(pattern=_OPTION_ID_PATTERN) + idempotency_key: StrictStr = Field(pattern=_IDEMPOTENCY_KEY_PATTERN) + authenticated_principal_ref: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + authenticated_role_ref: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + authn_assurance: BusinessDecisionRequiredAuthn + authenticated_route_ref: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + role_mapping_digest: StrictStr = Field(pattern=_ROLE_MAPPING_DIGEST_PATTERN) + authentication_context_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + answered_at: StrictStr = Field( + min_length=20, max_length=40, pattern=_TIMESTAMP_PATTERN + ) + issuer_key_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + signature_algorithm: Literal["hmac-sha256"] = "hmac-sha256" + signature: StrictStr = Field(pattern=_SIGNATURE_PATTERN) + + @model_validator(mode="after") + def _validate_answered_at(self) -> BusinessDecisionAnswerV1: + _parse_timestamp(self.answered_at, "answered_at") + return self + + def unsigned_payload(self) -> dict[str, Any]: + return self.model_dump(mode="json", exclude={"signature"}) + + def canonical_unsigned_bytes(self) -> bytes: + return _canonical_json(self.unsigned_payload()) + + @property + def digest(self) -> str: + return _digest(self.canonical_unsigned_bytes()) + + def verify_hmac(self, key: bytes) -> bool: + return _verify_hmac( + key, self.unsigned_payload(), self.signature, _ANSWER_DOMAIN + ) + + +class BusinessDecisionAnswerReceiptState(str, Enum): + """A runner result for one answer. No member claims a verified effect.""" + + ANSWER_RECORDED = "answer_recorded" + REFUSED = "refused" + EXPIRED = "expired" + SUPERSEDED = "superseded" + DELIVERY_UNCERTAIN = "delivery_uncertain" + + +class BusinessDecisionAnswerReceiptReason(str, Enum): + RECORDED_PENDING_REVALIDATION = "recorded_pending_revalidation" + AUTHORIZATION_REFUSED = "authorization_refused" + OPTION_REFUSED = "option_refused" + EVIDENCE_REFUSED = "evidence_refused" + REVALIDATION_REFUSED = "revalidation_refused" + EXPIRED = "expired" + SUPERSEDED = "superseded" + DELIVERY_UNCERTAIN = "delivery_uncertain" + + +BUSINESS_DECISION_ANSWER_RECEIPT_REASONS: Mapping[ + BusinessDecisionAnswerReceiptState, frozenset[BusinessDecisionAnswerReceiptReason] +] = { + BusinessDecisionAnswerReceiptState.ANSWER_RECORDED: frozenset( + {BusinessDecisionAnswerReceiptReason.RECORDED_PENDING_REVALIDATION} + ), + BusinessDecisionAnswerReceiptState.REFUSED: frozenset( + { + BusinessDecisionAnswerReceiptReason.AUTHORIZATION_REFUSED, + BusinessDecisionAnswerReceiptReason.OPTION_REFUSED, + BusinessDecisionAnswerReceiptReason.EVIDENCE_REFUSED, + BusinessDecisionAnswerReceiptReason.REVALIDATION_REFUSED, + } + ), + BusinessDecisionAnswerReceiptState.EXPIRED: frozenset( + {BusinessDecisionAnswerReceiptReason.EXPIRED} + ), + BusinessDecisionAnswerReceiptState.SUPERSEDED: frozenset( + {BusinessDecisionAnswerReceiptReason.SUPERSEDED} + ), + BusinessDecisionAnswerReceiptState.DELIVERY_UNCERTAIN: frozenset( + {BusinessDecisionAnswerReceiptReason.DELIVERY_UNCERTAIN} + ), +} + + +class BusinessDecisionAnswerReceiptV1(_StrictContract): + """A signed runner result that reports answer handling, not business success.""" + + schema_version: Literal["openadapt.business-decision-answer-receipt/v1"] = ( + BUSINESS_DECISION_ANSWER_RECEIPT_SCHEMA + ) + task_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + task_revision: StrictInt = Field(ge=1, le=2_147_483_647) + task_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + request_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + answer_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + option_id: StrictStr = Field(pattern=_OPTION_ID_PATTERN) + state: BusinessDecisionAnswerReceiptState + reason_code: BusinessDecisionAnswerReceiptReason + runner_decision_receipt_digest: StrictStr | None = Field( + default=None, pattern=_SHA256_PATTERN + ) + decided_at: StrictStr = Field( + min_length=20, max_length=40, pattern=_TIMESTAMP_PATTERN + ) + issuer_key_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + signature_algorithm: Literal["hmac-sha256"] = "hmac-sha256" + signature: StrictStr = Field(pattern=_SIGNATURE_PATTERN) + + @model_validator(mode="after") + def _validate_receipt(self) -> BusinessDecisionAnswerReceiptV1: + if self.reason_code not in BUSINESS_DECISION_ANSWER_RECEIPT_REASONS[self.state]: + raise ValueError("business decision receipt state and reason disagree") + has_local_receipt = ( + self.state is BusinessDecisionAnswerReceiptState.ANSWER_RECORDED + or self.reason_code is BusinessDecisionAnswerReceiptReason.REVALIDATION_REFUSED + ) + if has_local_receipt: + if self.runner_decision_receipt_digest is None: + raise ValueError( + "an answer-recorded receipt requires the runner receipt digest" + ) + elif self.runner_decision_receipt_digest is not None: + raise ValueError( + "a non-recorded result cannot claim a runner decision receipt" + ) + _parse_timestamp(self.decided_at, "decided_at") + return self + + @property + def succeeded(self) -> Literal[False]: + """A business-answer receipt never proves the workflow's business effect.""" + + return False + + def unsigned_payload(self) -> dict[str, Any]: + return self.model_dump(mode="json", exclude={"signature"}) + + def canonical_unsigned_bytes(self) -> bytes: + return _canonical_json(self.unsigned_payload()) + + @property + def digest(self) -> str: + return _digest(self.canonical_unsigned_bytes()) + + def verify_hmac(self, key: bytes) -> bool: + return _verify_hmac( + key, self.unsigned_payload(), self.signature, _ANSWER_RECEIPT_DOMAIN + ) + + +def validate_business_decision_answer( + task: BusinessDecisionTaskV1, + answer: BusinessDecisionAnswerV1, + *, + task_signing_key: bytes, + answer_signing_key: bytes, + at: str, +) -> None: + """Fail unless an authenticated answer matches one active remote task. + + This check does not authorize execution. It is the portable admission + check before the customer runner calls Flow's authoritative decision API. + """ + + if not task.verify_hmac(task_signing_key): + raise ValueError("the business decision task signature is invalid") + if not answer.verify_hmac(answer_signing_key): + raise ValueError("the business decision answer signature is invalid") + at_time = _parse_timestamp(at, "at") + if task.delivery_mode is not BusinessDecisionDeliveryMode.REMOTE_ANSWERABLE: + raise ValueError("this business decision requires a local answer") + created_at = _parse_timestamp(task.created_at, "created_at") + expires_at = _parse_timestamp(task.expires_at, "expires_at") + if at_time < created_at: + raise ValueError("the business decision task is not active yet") + if at_time >= expires_at: + raise ValueError("the business decision task expired") + expected = { + "task_id": (answer.task_id, task.task_id), + "task_revision": (answer.task_revision, task.task_revision), + "task_digest": (answer.task_digest, task.digest), + "request_digest": (answer.request_digest, task.request_digest), + "role_mapping_digest": ( + answer.role_mapping_digest, + task.role_mapping_digest, + ), + "authn_assurance": (answer.authn_assurance, task.required_authn), + } + for name, (actual, required) in expected.items(): + if actual != required: + raise ValueError(f"business decision {name} does not match the task") + if answer.authenticated_role_ref not in set(task.authorized_role_refs): + raise ValueError("the authenticated role is not authorized for the decision") + if answer.authenticated_route_ref not in set(task.authorized_route_refs): + raise ValueError("the authenticated route is not authorized for the decision") + if answer.issuer_key_id not in set(task.authorized_answer_issuer_key_ids): + raise ValueError("the answer signing key is not authorized for the decision") + if answer.option_id not in {option.option_id for option in task.options}: + raise ValueError("the business decision option is not permitted") + answered_at = _parse_timestamp(answer.answered_at, "answered_at") + if answered_at < created_at: + raise ValueError("the business decision answer predates the task") + if answered_at >= expires_at: + raise ValueError("the business decision answer was created after expiry") + if at_time < answered_at: + raise ValueError("the business decision answer is from the future") + + +def sign_business_decision_task_hmac( + *, key: bytes, fields: Mapping[str, Any] +) -> BusinessDecisionTaskV1: + return _sign(BusinessDecisionTaskV1, key, fields, _TASK_DOMAIN) + + +def sign_business_decision_answer_hmac( + *, key: bytes, fields: Mapping[str, Any] +) -> BusinessDecisionAnswerV1: + return _sign(BusinessDecisionAnswerV1, key, fields, _ANSWER_DOMAIN) + + +def sign_business_decision_delivery_policy_hmac( + *, key: bytes, fields: Mapping[str, Any] +) -> BusinessDecisionDeliveryPolicyV1: + return _sign( + BusinessDecisionDeliveryPolicyV1, + key, + fields, + _DELIVERY_POLICY_DOMAIN, + ) + + +def sign_business_decision_answer_receipt_hmac( + *, key: bytes, fields: Mapping[str, Any] +) -> BusinessDecisionAnswerReceiptV1: + return _sign(BusinessDecisionAnswerReceiptV1, key, fields, _ANSWER_RECEIPT_DOMAIN) + + +def _sign( + model: type[ + BusinessDecisionTaskV1 + | BusinessDecisionAnswerV1 + | BusinessDecisionDeliveryPolicyV1 + | BusinessDecisionAnswerReceiptV1 + ], + key: bytes, + fields: Mapping[str, Any], + domain: bytes, +) -> ( + BusinessDecisionTaskV1 + | BusinessDecisionAnswerV1 + | BusinessDecisionDeliveryPolicyV1 + | BusinessDecisionAnswerReceiptV1 +): + _validate_hmac_key(key) + if "signature" in fields: + raise ValueError("fields must not contain a signature") + validated = model.model_validate( + { + **dict(fields), + "signature_algorithm": "hmac-sha256", + "signature": "hmac-sha256:" + "0" * 64, + } + ) + signature = _hmac_signature(key, validated.unsigned_payload(), domain) + return validated.model_copy(update={"signature": signature}) + + +def _validate_hmac_key(key: bytes) -> None: + if not isinstance(key, bytes) or len(key) < 32: + raise ValueError("business decision HMAC key must contain at least 32 bytes") + + +def _parse_timestamp(value: str, field_name: str) -> datetime: + 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 _canonical_json(payload: Mapping[str, Any]) -> bytes: + return json.dumps( + payload, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def _digest(payload: bytes) -> str: + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def _hmac_signature(key: bytes, payload: Mapping[str, Any], domain: bytes) -> str: + return ( + "hmac-sha256:" + + hmac.new(key, domain + _canonical_json(payload), hashlib.sha256).hexdigest() + ) + + +def _verify_hmac( + key: bytes, + payload: Mapping[str, Any], + signature: str, + domain: bytes, +) -> bool: + _validate_hmac_key(key) + return hmac.compare_digest(signature, _hmac_signature(key, payload, domain)) diff --git a/openadapt_types/schemas/business-decision-answer-receipt-v1.json b/openadapt_types/schemas/business-decision-answer-receipt-v1.json new file mode 100644 index 0000000..9f9139a --- /dev/null +++ b/openadapt_types/schemas/business-decision-answer-receipt-v1.json @@ -0,0 +1,128 @@ +{ + "$defs": { + "BusinessDecisionAnswerReceiptReason": { + "enum": [ + "recorded_pending_revalidation", + "authorization_refused", + "option_refused", + "evidence_refused", + "revalidation_refused", + "expired", + "superseded", + "delivery_uncertain" + ], + "title": "BusinessDecisionAnswerReceiptReason", + "type": "string" + }, + "BusinessDecisionAnswerReceiptState": { + "description": "A runner result for one answer. No member claims a verified effect.", + "enum": [ + "answer_recorded", + "refused", + "expired", + "superseded", + "delivery_uncertain" + ], + "title": "BusinessDecisionAnswerReceiptState", + "type": "string" + } + }, + "additionalProperties": false, + "description": "A signed runner result that reports answer handling, not business success.", + "properties": { + "answer_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Answer Digest", + "type": "string" + }, + "decided_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Decided At", + "type": "string" + }, + "issuer_key_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Issuer Key Id", + "type": "string" + }, + "option_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "title": "Option Id", + "type": "string" + }, + "reason_code": { + "$ref": "#/$defs/BusinessDecisionAnswerReceiptReason" + }, + "request_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Request Digest", + "type": "string" + }, + "runner_decision_receipt_digest": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Runner Decision Receipt Digest" + }, + "schema_version": { + "const": "openadapt.business-decision-answer-receipt/v1", + "default": "openadapt.business-decision-answer-receipt/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Signature", + "type": "string" + }, + "signature_algorithm": { + "const": "hmac-sha256", + "default": "hmac-sha256", + "title": "Signature Algorithm", + "type": "string" + }, + "state": { + "$ref": "#/$defs/BusinessDecisionAnswerReceiptState" + }, + "task_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Task Digest", + "type": "string" + }, + "task_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Task Id", + "type": "string" + }, + "task_revision": { + "maximum": 2147483647, + "minimum": 1, + "title": "Task Revision", + "type": "integer" + } + }, + "required": [ + "task_id", + "task_revision", + "task_digest", + "request_digest", + "answer_digest", + "option_id", + "state", + "reason_code", + "decided_at", + "issuer_key_id", + "signature" + ], + "title": "BusinessDecisionAnswerReceiptV1", + "type": "object" +} diff --git a/openadapt_types/schemas/business-decision-answer-v1.json b/openadapt_types/schemas/business-decision-answer-v1.json new file mode 100644 index 0000000..eeae700 --- /dev/null +++ b/openadapt_types/schemas/business-decision-answer-v1.json @@ -0,0 +1,125 @@ +{ + "$defs": { + "BusinessDecisionRequiredAuthn": { + "description": "The exact authentication profile required by the qualified contract.", + "enum": [ + "local_enterprise_identity", + "aal2", + "webauthn" + ], + "title": "BusinessDecisionRequiredAuthn", + "type": "string" + } + }, + "additionalProperties": false, + "description": "An authenticated route's signed relay of one finite mobile answer.\n\nThe client chooses only ``option_id`` and supplies one idempotency key. The\nauthenticated route, not the client, must populate the principal, role, and\nauthentication-context references before it signs this contract.", + "properties": { + "answered_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Answered At", + "type": "string" + }, + "authenticated_principal_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Authenticated Principal Ref", + "type": "string" + }, + "authenticated_role_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Authenticated Role Ref", + "type": "string" + }, + "authenticated_route_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Authenticated Route Ref", + "type": "string" + }, + "authentication_context_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Authentication Context Digest", + "type": "string" + }, + "authn_assurance": { + "$ref": "#/$defs/BusinessDecisionRequiredAuthn" + }, + "idempotency_key": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{15,199}$", + "title": "Idempotency Key", + "type": "string" + }, + "issuer_key_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Issuer Key Id", + "type": "string" + }, + "option_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "title": "Option Id", + "type": "string" + }, + "request_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Request Digest", + "type": "string" + }, + "role_mapping_digest": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Role Mapping Digest", + "type": "string" + }, + "schema_version": { + "const": "openadapt.business-decision-answer/v1", + "default": "openadapt.business-decision-answer/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Signature", + "type": "string" + }, + "signature_algorithm": { + "const": "hmac-sha256", + "default": "hmac-sha256", + "title": "Signature Algorithm", + "type": "string" + }, + "task_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Task Digest", + "type": "string" + }, + "task_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Task Id", + "type": "string" + }, + "task_revision": { + "maximum": 2147483647, + "minimum": 1, + "title": "Task Revision", + "type": "integer" + } + }, + "required": [ + "task_id", + "task_revision", + "task_digest", + "request_digest", + "option_id", + "idempotency_key", + "authenticated_principal_ref", + "authenticated_role_ref", + "authn_assurance", + "authenticated_route_ref", + "role_mapping_digest", + "authentication_context_digest", + "answered_at", + "issuer_key_id", + "signature" + ], + "title": "BusinessDecisionAnswerV1", + "type": "object" +} diff --git a/openadapt_types/schemas/business-decision-delivery-policy-v1.json b/openadapt_types/schemas/business-decision-delivery-policy-v1.json new file mode 100644 index 0000000..6c9b5c4 --- /dev/null +++ b/openadapt_types/schemas/business-decision-delivery-policy-v1.json @@ -0,0 +1,162 @@ +{ + "$defs": { + "BusinessDecisionDeliveryMode": { + "description": "Whether the decision can leave the customer-controlled context.", + "enum": [ + "remote_answerable", + "local_answer_required" + ], + "title": "BusinessDecisionDeliveryMode", + "type": "string" + }, + "BusinessDecisionRequiredAuthn": { + "description": "The exact authentication profile required by the qualified contract.", + "enum": [ + "local_enterprise_identity", + "aal2", + "webauthn" + ], + "title": "BusinessDecisionRequiredAuthn", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Signed qualification policy for local or remote decision delivery.", + "properties": { + "authorized_answer_issuer_key_ids": { + "items": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "title": "Authorized Answer Issuer Key Ids", + "type": "array" + }, + "authorized_role_refs": { + "items": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "title": "Authorized Role Refs", + "type": "array" + }, + "authorized_route_refs": { + "items": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "title": "Authorized Route Refs", + "type": "array" + }, + "created_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Created At", + "type": "string" + }, + "decision_contract_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Decision Contract Digest", + "type": "string" + }, + "decision_contract_revision": { + "maximum": 2147483647, + "minimum": 1, + "title": "Decision Contract Revision", + "type": "integer" + }, + "delivery_mode": { + "$ref": "#/$defs/BusinessDecisionDeliveryMode" + }, + "expires_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Expires At", + "type": "string" + }, + "issuer_key_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Issuer Key Id", + "type": "string" + }, + "policy_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Policy Ref", + "type": "string" + }, + "policy_revision": { + "maximum": 2147483647, + "minimum": 1, + "title": "Policy Revision", + "type": "integer" + }, + "presentation_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Presentation Digest", + "type": "string" + }, + "presentation_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Presentation Ref", + "type": "string" + }, + "relay_capability_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Relay Capability Digest", + "type": "string" + }, + "required_authn": { + "$ref": "#/$defs/BusinessDecisionRequiredAuthn" + }, + "role_mapping_digest": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Role Mapping Digest", + "type": "string" + }, + "schema_version": { + "const": "openadapt.business-decision-delivery-policy/v1", + "default": "openadapt.business-decision-delivery-policy/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Signature", + "type": "string" + }, + "signature_algorithm": { + "const": "hmac-sha256", + "default": "hmac-sha256", + "title": "Signature Algorithm", + "type": "string" + } + }, + "required": [ + "policy_ref", + "policy_revision", + "decision_contract_digest", + "decision_contract_revision", + "presentation_ref", + "presentation_digest", + "authorized_role_refs", + "authorized_route_refs", + "authorized_answer_issuer_key_ids", + "role_mapping_digest", + "required_authn", + "delivery_mode", + "relay_capability_digest", + "created_at", + "expires_at", + "issuer_key_id", + "signature" + ], + "title": "BusinessDecisionDeliveryPolicyV1", + "type": "object" +} diff --git a/openadapt_types/schemas/business-decision-presentation-v1.json b/openadapt_types/schemas/business-decision-presentation-v1.json new file mode 100644 index 0000000..d3e07d7 --- /dev/null +++ b/openadapt_types/schemas/business-decision-presentation-v1.json @@ -0,0 +1,98 @@ +{ + "$defs": { + "BusinessDecisionPresentationOptionV1": { + "additionalProperties": false, + "description": "One exact option label from the reviewed workflow contract.", + "properties": { + "label": { + "maxLength": 120, + "minLength": 1, + "pattern": "^[^\\x00-\\x1f\\x7f]{1,500}$", + "title": "Label", + "type": "string" + }, + "option_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "title": "Option Id", + "type": "string" + } + }, + "required": [ + "option_id", + "label" + ], + "title": "BusinessDecisionPresentationOptionV1", + "type": "object" + } + }, + "additionalProperties": false, + "description": "Reviewed static copy for one exact qualified business decision.\n\nThis artifact is separate from the Cloud-safe task because it contains\nhuman-readable text. The qualification path must review it before remote\nuse. The signed task carries only this artifact's digest and opaque ref.", + "properties": { + "decision_contract_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Decision Contract Digest", + "type": "string" + }, + "decision_contract_revision": { + "maximum": 2147483647, + "minimum": 1, + "title": "Decision Contract Revision", + "type": "integer" + }, + "options": { + "items": { + "$ref": "#/$defs/BusinessDecisionPresentationOptionV1" + }, + "maxItems": 32, + "minItems": 2, + "title": "Options", + "type": "array" + }, + "presentation_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Presentation Ref", + "type": "string" + }, + "presentation_revision": { + "maximum": 2147483647, + "minimum": 1, + "title": "Presentation Revision", + "type": "integer" + }, + "question": { + "maxLength": 500, + "minLength": 1, + "pattern": "^[^\\x00-\\x1f\\x7f]{1,500}$", + "title": "Question", + "type": "string" + }, + "review_contract_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Review Contract Digest", + "type": "string" + }, + "reviewed_safe_for_remote": { + "const": true, + "default": true, + "title": "Reviewed Safe For Remote", + "type": "boolean" + }, + "schema_version": { + "const": "openadapt.business-decision-presentation/v1", + "default": "openadapt.business-decision-presentation/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "presentation_ref", + "presentation_revision", + "decision_contract_digest", + "decision_contract_revision", + "question", + "options", + "review_contract_digest" + ], + "title": "BusinessDecisionPresentationV1", + "type": "object" +} diff --git a/openadapt_types/schemas/business-decision-task-v1.json b/openadapt_types/schemas/business-decision-task-v1.json new file mode 100644 index 0000000..575b92c --- /dev/null +++ b/openadapt_types/schemas/business-decision-task-v1.json @@ -0,0 +1,317 @@ +{ + "$defs": { + "BusinessDecisionDeliveryMode": { + "description": "Whether the decision can leave the customer-controlled context.", + "enum": [ + "remote_answerable", + "local_answer_required" + ], + "title": "BusinessDecisionDeliveryMode", + "type": "string" + }, + "BusinessDecisionOptionBindingV1": { + "additionalProperties": false, + "description": "One finite option and its opaque compiled-successor commitment.", + "properties": { + "option_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "title": "Option Id", + "type": "string" + }, + "target_binding_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Target Binding Digest", + "type": "string" + } + }, + "required": [ + "option_id", + "target_binding_digest" + ], + "title": "BusinessDecisionOptionBindingV1", + "type": "object" + }, + "BusinessDecisionRequiredAuthn": { + "description": "The exact authentication profile required by the qualified contract.", + "enum": [ + "local_enterprise_identity", + "aal2", + "webauthn" + ], + "title": "BusinessDecisionRequiredAuthn", + "type": "string" + } + }, + "additionalProperties": false, + "description": "A signed remote-safe projection of one exact business-decision pause.", + "properties": { + "authorized_answer_issuer_key_ids": { + "items": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "title": "Authorized Answer Issuer Key Ids", + "type": "array" + }, + "authorized_role_refs": { + "items": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "title": "Authorized Role Refs", + "type": "array" + }, + "authorized_route_refs": { + "items": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "title": "Authorized Route Refs", + "type": "array" + }, + "bundle_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Bundle Digest", + "type": "string" + }, + "control_frames_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Control Frames Digest", + "type": "string" + }, + "created_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Created At", + "type": "string" + }, + "decision_contract_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Decision Contract Digest", + "type": "string" + }, + "decision_contract_revision": { + "maximum": 2147483647, + "minimum": 1, + "title": "Decision Contract Revision", + "type": "integer" + }, + "delivery_mode": { + "$ref": "#/$defs/BusinessDecisionDeliveryMode" + }, + "delivery_policy_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Delivery Policy Digest", + "type": "string" + }, + "expires_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Expires At", + "type": "string" + }, + "governed_runtime_inputs_digest": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Governed Runtime Inputs Digest" + }, + "idempotency_scope_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Idempotency Scope Digest", + "type": "string" + }, + "issuer_key_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Issuer Key Id", + "type": "string" + }, + "local_evidence_required": { + "title": "Local Evidence Required", + "type": "boolean" + }, + "options": { + "items": { + "$ref": "#/$defs/BusinessDecisionOptionBindingV1" + }, + "maxItems": 32, + "minItems": 2, + "title": "Options", + "type": "array" + }, + "pause_binding_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Pause Binding Digest", + "type": "string" + }, + "pause_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Pause Id", + "type": "string" + }, + "presentation_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Presentation Digest", + "type": "string" + }, + "presentation_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Presentation Ref", + "type": "string" + }, + "program_scope_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Program Scope Digest", + "type": "string" + }, + "relay_capability_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Relay Capability Digest", + "type": "string" + }, + "request_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Request Digest", + "type": "string" + }, + "request_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Request Id", + "type": "string" + }, + "request_revision": { + "default": 1, + "maximum": 2147483647, + "minimum": 1, + "title": "Request Revision", + "type": "integer" + }, + "required_authn": { + "$ref": "#/$defs/BusinessDecisionRequiredAuthn" + }, + "required_evidence_count": { + "default": 0, + "maximum": 64, + "minimum": 0, + "title": "Required Evidence Count", + "type": "integer" + }, + "role_mapping_digest": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Role Mapping Digest", + "type": "string" + }, + "run_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Run Id", + "type": "string" + }, + "runner_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Runner Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.business-decision-task/v1", + "default": "openadapt.business-decision-task/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Signature", + "type": "string" + }, + "signature_algorithm": { + "const": "hmac-sha256", + "default": "hmac-sha256", + "title": "Signature Algorithm", + "type": "string" + }, + "supersedes_request_digest": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Supersedes Request Digest" + }, + "task_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Task Id", + "type": "string" + }, + "task_revision": { + "default": 1, + "maximum": 2147483647, + "minimum": 1, + "title": "Task Revision", + "type": "integer" + }, + "tenant_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Tenant Id", + "type": "string" + }, + "workflow_contract_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Workflow Contract Digest", + "type": "string" + } + }, + "required": [ + "task_id", + "tenant_id", + "runner_id", + "run_id", + "pause_id", + "pause_binding_digest", + "request_id", + "request_digest", + "bundle_digest", + "workflow_contract_digest", + "decision_contract_digest", + "decision_contract_revision", + "delivery_policy_digest", + "program_scope_digest", + "control_frames_digest", + "presentation_ref", + "presentation_digest", + "options", + "authorized_role_refs", + "authorized_route_refs", + "authorized_answer_issuer_key_ids", + "role_mapping_digest", + "required_authn", + "delivery_mode", + "local_evidence_required", + "relay_capability_digest", + "idempotency_scope_digest", + "created_at", + "expires_at", + "issuer_key_id", + "signature" + ], + "title": "BusinessDecisionTaskV1", + "type": "object" +} diff --git a/scripts/export_control_overlay_schemas.py b/scripts/export_control_overlay_schemas.py index 372a0e3..35e9fe6 100644 --- a/scripts/export_control_overlay_schemas.py +++ b/scripts/export_control_overlay_schemas.py @@ -8,6 +8,11 @@ from typing import Any, cast from openadapt_types import ( + BusinessDecisionAnswerReceiptV1, + BusinessDecisionAnswerV1, + BusinessDecisionDeliveryPolicyV1, + BusinessDecisionPresentationV1, + BusinessDecisionTaskV1, ControlOverlayFrameV1, ControlOverlayFrameV2, ControlOverlayTimelineV1, @@ -24,6 +29,11 @@ ROOT = Path(__file__).resolve().parents[1] SCHEMA_DIR = ROOT / "openadapt_types" / "schemas" SCHEMAS = { + "business-decision-task-v1.json": BusinessDecisionTaskV1, + "business-decision-answer-v1.json": BusinessDecisionAnswerV1, + "business-decision-answer-receipt-v1.json": BusinessDecisionAnswerReceiptV1, + "business-decision-presentation-v1.json": BusinessDecisionPresentationV1, + "business-decision-delivery-policy-v1.json": BusinessDecisionDeliveryPolicyV1, "control-overlay-frame-v1.json": ControlOverlayFrameV1, "control-overlay-timeline-v1.json": ControlOverlayTimelineV1, "control-overlay-frame-v2.json": ControlOverlayFrameV2, diff --git a/tests/test_business_decision.py b/tests/test_business_decision.py new file mode 100644 index 0000000..d44c15d --- /dev/null +++ b/tests/test_business_decision.py @@ -0,0 +1,418 @@ +"""Contract tests for portable finite business decisions.""" + +from __future__ import annotations + +import json +from importlib.resources import files + +import pytest +from pydantic import ValidationError + +from openadapt_types import ( + BusinessDecisionAnswerReceiptState, + BusinessDecisionAnswerV1, + BusinessDecisionDeliveryPolicyV1, + BusinessDecisionPresentationV1, + BusinessDecisionTaskV1, + sign_business_decision_answer_hmac, + sign_business_decision_answer_receipt_hmac, + sign_business_decision_delivery_policy_hmac, + sign_business_decision_task_hmac, + validate_business_decision_answer, +) + +KEY = b"k" * 32 + + +def _digest(character: str) -> str: + return "sha256:" + character * 64 + + +def _task_fields() -> dict[str, object]: + return { + "task_id": "task_12345678", + "task_revision": 3, + "tenant_id": "tenant_12345678", + "runner_id": "runner_12345678", + "run_id": "run_1234567890", + "pause_id": "pause_12345678", + "pause_binding_digest": _digest("0"), + "request_id": "request_123456", + "request_revision": 2, + "request_digest": _digest("1"), + "supersedes_request_digest": _digest("2"), + "bundle_digest": _digest("3"), + "workflow_contract_digest": _digest("5"), + "governed_runtime_inputs_digest": _digest("6"), + "decision_contract_digest": _digest("7"), + "decision_contract_revision": 4, + "delivery_policy_digest": _digest("4"), + "program_scope_digest": _digest("8"), + "control_frames_digest": _digest("9"), + "presentation_ref": "present_12345678", + "presentation_digest": _digest("a"), + "options": ( + {"option_id": "approve", "target_binding_digest": _digest("b")}, + {"option_id": "decline", "target_binding_digest": _digest("c")}, + ), + "authorized_role_refs": ("role_approver_01", "role_reviewer_01"), + "authorized_route_refs": ("route_mobile_001",), + "authorized_answer_issuer_key_ids": ("cloud_signing_001",), + "role_mapping_digest": "hmac-sha256:" + "d" * 64, + "required_authn": "aal2", + "delivery_mode": "remote_answerable", + "local_evidence_required": False, + "required_evidence_count": 0, + "relay_capability_digest": _digest("e"), + "idempotency_scope_digest": _digest("f"), + "created_at": "2026-08-08T12:00:00Z", + "expires_at": "2026-08-08T12:05:00Z", + "issuer_key_id": "runner_signing_01", + } + + +def _answer_fields(task: BusinessDecisionTaskV1) -> dict[str, object]: + return { + "task_id": task.task_id, + "task_revision": task.task_revision, + "task_digest": task.digest, + "request_digest": task.request_digest, + "option_id": "approve", + "idempotency_key": "answer_attempt_01", + "authenticated_principal_ref": "principal_123456", + "authenticated_role_ref": task.authorized_role_refs[0], + "authn_assurance": task.required_authn, + "authenticated_route_ref": task.authorized_route_refs[0], + "role_mapping_digest": task.role_mapping_digest, + "authentication_context_digest": _digest("0"), + "answered_at": "2026-08-08T12:01:00Z", + "issuer_key_id": "cloud_signing_001", + } + + +def test_signed_task_binds_the_complete_remote_authority_context() -> None: + task = sign_business_decision_task_hmac(key=KEY, fields=_task_fields()) + assert task.verify_hmac(KEY) + + changes = { + "tenant_id": "tenant_87654321", + "runner_id": "runner_87654321", + "run_id": "run_8765432100", + "pause_id": "pause_87654321", + "pause_binding_digest": _digest("a"), + "request_digest": _digest("0"), + "bundle_digest": _digest("0"), + "workflow_contract_digest": _digest("0"), + "decision_contract_digest": _digest("0"), + "decision_contract_revision": 5, + "authorized_role_refs": ("role_reviewer_01",), + "authorized_route_refs": ("route_mobile_002",), + "authorized_answer_issuer_key_ids": ("cloud_signing_002",), + "expires_at": "2026-08-08T12:06:00Z", + "idempotency_scope_digest": _digest("0"), + } + for field, value in changes.items(): + assert not task.model_copy(update={field: value}).verify_hmac(KEY) + + +def test_answer_admission_requires_exact_option_role_revision_and_expiry() -> None: + task = sign_business_decision_task_hmac(key=KEY, fields=_task_fields()) + answer = sign_business_decision_answer_hmac( + key=KEY, + fields=_answer_fields(task), + ) + validate_business_decision_answer( + task, + answer, + task_signing_key=KEY, + answer_signing_key=KEY, + at="2026-08-08T12:01:01Z", + ) + + refused = { + "option_id": "other", + "authenticated_role_ref": "role_unknown_000", + "task_revision": 2, + "request_digest": _digest("0"), + "authn_assurance": "local_enterprise_identity", + "authenticated_route_ref": "route_unknown_01", + "issuer_key_id": "cloud_signing_999", + } + for field, value in refused.items(): + changed = sign_business_decision_answer_hmac( + key=KEY, + fields={**_answer_fields(task), field: value}, + ) + with pytest.raises(ValueError): + validate_business_decision_answer( + task, + changed, + task_signing_key=KEY, + answer_signing_key=KEY, + at="2026-08-08T12:01:01Z", + ) + + with pytest.raises(ValueError, match="expired"): + validate_business_decision_answer( + task, + answer, + task_signing_key=KEY, + answer_signing_key=KEY, + at="2026-08-08T12:05:01Z", + ) + + +def test_local_context_task_cannot_be_answered_remotely() -> None: + task = sign_business_decision_task_hmac( + key=KEY, + fields={ + **_task_fields(), + "delivery_mode": "local_answer_required", + "local_evidence_required": True, + "required_evidence_count": 1, + }, + ) + answer = sign_business_decision_answer_hmac(key=KEY, fields=_answer_fields(task)) + with pytest.raises(ValueError, match="local answer"): + validate_business_decision_answer( + task, + answer, + task_signing_key=KEY, + answer_signing_key=KEY, + at="2026-08-08T12:01:01Z", + ) + + +def test_answer_admission_authenticates_both_signed_envelopes() -> None: + task = sign_business_decision_task_hmac(key=KEY, fields=_task_fields()) + answer = sign_business_decision_answer_hmac(key=KEY, fields=_answer_fields(task)) + + with pytest.raises(ValueError, match="task signature"): + validate_business_decision_answer( + task, + answer, + task_signing_key=b"x" * 32, + answer_signing_key=KEY, + at="2026-08-08T12:01:01Z", + ) + with pytest.raises(ValueError, match="answer signature"): + validate_business_decision_answer( + task, + answer, + task_signing_key=KEY, + answer_signing_key=b"x" * 32, + at="2026-08-08T12:01:01Z", + ) + + with pytest.raises(ValidationError, match="protected local evidence"): + sign_business_decision_task_hmac( + key=KEY, + fields={ + **_task_fields(), + "delivery_mode": "remote_answerable", + "local_evidence_required": True, + "required_evidence_count": 1, + }, + ) + + +def test_renewal_requires_one_exact_predecessor_binding() -> None: + with pytest.raises(ValidationError, match="first request revision"): + sign_business_decision_task_hmac( + key=KEY, + fields={**_task_fields(), "request_revision": 1}, + ) + with pytest.raises(ValidationError, match="bind its predecessor"): + sign_business_decision_task_hmac( + key=KEY, + fields={**_task_fields(), "supersedes_request_digest": None}, + ) + + +def test_runner_receipt_never_reports_business_success() -> None: + task = sign_business_decision_task_hmac(key=KEY, fields=_task_fields()) + answer = sign_business_decision_answer_hmac(key=KEY, fields=_answer_fields(task)) + receipt = sign_business_decision_answer_receipt_hmac( + key=KEY, + fields={ + "task_id": task.task_id, + "task_revision": task.task_revision, + "task_digest": task.digest, + "request_digest": task.request_digest, + "answer_digest": answer.digest, + "option_id": answer.option_id, + "state": "answer_recorded", + "reason_code": "recorded_pending_revalidation", + "runner_decision_receipt_digest": _digest("1"), + "decided_at": "2026-08-08T12:01:01Z", + "issuer_key_id": "runner_signing_01", + }, + ) + assert receipt.verify_hmac(KEY) + assert receipt.state is BusinessDecisionAnswerReceiptState.ANSWER_RECORDED + assert receipt.succeeded is False + assert "verified" not in json.dumps(receipt.model_dump(mode="json")) + assert "report_success" not in type(receipt).model_fields + + +def test_delivery_policy_binds_reviewed_copy_and_remote_authority() -> None: + presentation = BusinessDecisionPresentationV1( + presentation_ref="present_12345678", + presentation_revision=1, + decision_contract_digest=_digest("7"), + decision_contract_revision=4, + question="Which reviewed path should continue?", + options=( + {"option_id": "approve", "label": "Approve"}, + {"option_id": "decline", "label": "Decline"}, + ), + review_contract_digest=_digest("8"), + ) + policy = sign_business_decision_delivery_policy_hmac( + key=KEY, + fields={ + "policy_ref": "policy_12345678", + "policy_revision": 1, + "decision_contract_digest": presentation.decision_contract_digest, + "decision_contract_revision": presentation.decision_contract_revision, + "presentation_ref": presentation.presentation_ref, + "presentation_digest": presentation.digest, + "authorized_role_refs": ("role_approver_01",), + "authorized_route_refs": ("route_mobile_001",), + "authorized_answer_issuer_key_ids": ("cloud_signing_001",), + "role_mapping_digest": "hmac-sha256:" + "d" * 64, + "required_authn": "aal2", + "delivery_mode": "remote_answerable", + "relay_capability_digest": _digest("e"), + "created_at": "2026-08-08T12:00:00Z", + "expires_at": "2026-08-08T12:05:00Z", + "issuer_key_id": "qualification_key_01", + }, + ) + assert policy.verify_hmac(KEY) + assert policy.presentation_digest == presentation.digest + + +def test_short_answer_idempotency_key_is_refused() -> None: + task = sign_business_decision_task_hmac(key=KEY, fields=_task_fields()) + with pytest.raises(ValidationError, match="idempotency_key"): + sign_business_decision_answer_hmac( + key=KEY, + fields={**_answer_fields(task), "idempotency_key": "too-short"}, + ) + + +def test_cross_language_digest_and_signature_vectors_are_stable() -> None: + task = sign_business_decision_task_hmac(key=KEY, fields=_task_fields()) + answer = sign_business_decision_answer_hmac(key=KEY, fields=_answer_fields(task)) + receipt = sign_business_decision_answer_receipt_hmac( + key=KEY, + fields={ + "task_id": task.task_id, + "task_revision": task.task_revision, + "task_digest": task.digest, + "request_digest": task.request_digest, + "answer_digest": answer.digest, + "option_id": answer.option_id, + "state": "answer_recorded", + "reason_code": "recorded_pending_revalidation", + "runner_decision_receipt_digest": _digest("1"), + "decided_at": "2026-08-08T12:01:01Z", + "issuer_key_id": "runner_signing_01", + }, + ) + + assert (task.digest, task.signature) == ( + "sha256:79d37ef8c8a913e171f9703119337f5ddfefb62808d1154904b428bf0a31c8a4", + "hmac-sha256:347549b44bf01b10cf0f41ee15034cfca49c4b851797caff95b7ae473a37531d", + ) + assert (answer.digest, answer.signature) == ( + "sha256:2c84e3c2adda8c234131aa9920e54da4ac025a86e9c51dba3ec06ab2b60e6153", + "hmac-sha256:cdb985b640e7cc3c668a9a7d0e78e12990de1e247a6edc6b6bfaa31e0567f412", + ) + assert (receipt.digest, receipt.signature) == ( + "sha256:70a83847d4b77d284953e9b0e148436488c8648237e01b27ca4a91ea641a63be", + "hmac-sha256:7cdb2795d0bb896757eaaf8c148bdc3240e870b243e2a94fdea87cc37f1bbe81", + ) + + +def _unconstrained_string_paths(schema: object) -> list[str]: + paths: list[str] = [] + + def visit(node: object, path: str) -> None: + if isinstance(node, dict): + if node.get("type") == "string" and not ( + {"pattern", "const", "enum"} & set(node) + ): + paths.append(path) + for key, value in node.items(): + visit(value, f"{path}/{key}") + elif isinstance(node, list): + for index, value in enumerate(node): + visit(value, f"{path}/{index}") + + visit(schema, "") + return paths + + +@pytest.mark.parametrize( + "model,filename", + [ + (BusinessDecisionTaskV1, "business-decision-task-v1.json"), + (BusinessDecisionAnswerV1, "business-decision-answer-v1.json"), + (BusinessDecisionPresentationV1, "business-decision-presentation-v1.json"), + (BusinessDecisionDeliveryPolicyV1, "business-decision-delivery-policy-v1.json"), + ], +) +def test_cloud_safe_contract_has_no_free_text_or_sensitive_extension( + model: type[BusinessDecisionTaskV1 | BusinessDecisionAnswerV1], + filename: str, +) -> None: + schema = model.model_json_schema() + assert schema["additionalProperties"] is False + assert _unconstrained_string_paths(schema) == [] + encoded = json.dumps(schema).lower() + for term in ("screenshot", "ocr", "patient", "claim", "loan", "message"): + assert term not in encoded + packaged = files("openadapt_types.schemas").joinpath(filename) + assert json.loads(packaged.read_text()) == schema + + +def test_unknown_live_context_fields_are_structurally_refused() -> None: + task = sign_business_decision_task_hmac(key=KEY, fields=_task_fields()) + payload = task.model_dump(mode="json") + for field, value in { + "screenshot": "data:image/png;base64,secret", + "record_id": "ABC-123", + "question": "Should this person be approved?", + "option_label": "Approve the claim", + }.items(): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + BusinessDecisionTaskV1.model_validate({**payload, field: value}) + + +def test_option_ids_are_finite_and_unique() -> None: + with pytest.raises(ValidationError, match="option ids must be unique"): + sign_business_decision_task_hmac( + key=KEY, + fields={ + **_task_fields(), + "options": ( + {"option_id": "approve", "target_binding_digest": _digest("b")}, + {"option_id": "approve", "target_binding_digest": _digest("c")}, + ), + }, + ) + + too_many = tuple( + { + "option_id": f"option_{index}", + "target_binding_digest": _digest(format(index, "x")[-1]), + } + for index in range(33) + ) + with pytest.raises(ValidationError): + sign_business_decision_task_hmac( + key=KEY, + fields={**_task_fields(), "options": too_many}, + ) From 5cb8affd9d46e3efa03f20f80ecbcebf6034542d Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 8 Aug 2026 18:17:28 +0200 Subject: [PATCH 2/3] docs: clarify decision presentation trust --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6f7f9f6..6af4360 100644 --- a/README.md +++ b/README.md @@ -209,10 +209,11 @@ It keeps `waiting_for_reconciliation` as a lifecycle state and The separate `business-decision-*-v1.json` files define a finite human branch that the workflow declared before execution. They do not reuse the operational halt actions. They carry only opaque bindings, option IDs, digests, counts, and -closed status values. The reviewed question and option text live in a signed -presentation artifact, while screenshots and live record values stay on the -customer runner. An accepted answer only selects a compiled branch. The next -action must still pass its live-state, identity, policy, and effect contracts. +closed status values. The reviewed question and option text live in a +presentation artifact whose digest is bound by the signed delivery policy. +Screenshots and live record values stay on the customer runner. An accepted +answer only selects a compiled branch. The next action must still pass its +live-state, identity, policy, and effect contracts. ## OpenAdapt Execute v1 From 26698668b286f43444f44298ce0ba3f8166d4bb7 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 8 Aug 2026 20:57:28 +0200 Subject: [PATCH 3/3] fix: bind reviewed business decision presentation --- README.md | 12 +- openadapt_types/__init__.py | 16 + openadapt_types/business_decision.py | 312 ++++++++++++++-- openadapt_types/execute.py | 11 +- .../business-decision-delivery-policy-v1.json | 13 + .../business-decision-presentation-v1.json | 203 ++++++++++- .../schemas/execute-v1-openapi.json | 332 +++++++++++++++++- tests/test_business_decision.py | 283 ++++++++++++++- 8 files changed, 1128 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 6af4360..30d8651 100644 --- a/README.md +++ b/README.md @@ -209,11 +209,13 @@ It keeps `waiting_for_reconciliation` as a lifecycle state and The separate `business-decision-*-v1.json` files define a finite human branch that the workflow declared before execution. They do not reuse the operational halt actions. They carry only opaque bindings, option IDs, digests, counts, and -closed status values. The reviewed question and option text live in a -presentation artifact whose digest is bound by the signed delivery policy. -Screenshots and live record values stay on the customer runner. An accepted -answer only selects a compiled branch. The next action must still pass its -live-state, identity, policy, and effect contracts. +closed status values. The separate presentation artifact classifies each +question and option label as `local_only` or `reviewed_remote_safe`. Remote +delivery requires a positive egress-review digest for every field. The signed +delivery policy binds the exact presentation and review digest. Screenshots, +free-form notes, and live record values stay on the customer runner. An +accepted answer only selects a compiled branch. The next action must still pass +its live-state, identity, policy, and effect contracts. ## OpenAdapt Execute v1 diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index c6c38da..5306572 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -45,18 +45,25 @@ BusinessDecisionAnswerReceiptState, BusinessDecisionAnswerReceiptV1, BusinessDecisionAnswerV1, + BusinessDecisionContextCardV1, + BusinessDecisionContextKind, BusinessDecisionDeliveryMode, BusinessDecisionDeliveryPolicyV1, + BusinessDecisionJudgmentReason, BusinessDecisionOptionBindingV1, + BusinessDecisionPresentationClassification, BusinessDecisionPresentationOptionV1, + BusinessDecisionPresentationTextV1, BusinessDecisionPresentationV1, BusinessDecisionRequiredAuthn, BusinessDecisionTaskV1, + business_decision_presentation_text_digest, sign_business_decision_answer_hmac, sign_business_decision_answer_receipt_hmac, sign_business_decision_delivery_policy_hmac, sign_business_decision_task_hmac, validate_business_decision_answer, + validate_business_decision_delivery, ) from openadapt_types.computer_state import ( BoundingBox, @@ -116,6 +123,7 @@ ExecuteAcceptedV1, ExecuteAuthorizationContextV1, ExecuteDecisionRequiredWebhookV1, + ExecuteDecisionTaskV1, ExecuteEvidenceContractV1, ExecuteEvidenceReceiptV1, ExecuteLifecycleStateV1, @@ -257,21 +265,28 @@ "BUSINESS_DECISION_PRESENTATION_SCHEMA", "BUSINESS_DECISION_TASK_SCHEMA", "BusinessDecisionAnswerV1", + "BusinessDecisionContextCardV1", + "BusinessDecisionContextKind", "BusinessDecisionDeliveryPolicyV1", "BusinessDecisionDeliveryMode", + "BusinessDecisionJudgmentReason", "BusinessDecisionOptionBindingV1", + "BusinessDecisionPresentationClassification", "BusinessDecisionPresentationOptionV1", + "BusinessDecisionPresentationTextV1", "BusinessDecisionPresentationV1", "BusinessDecisionAnswerReceiptReason", "BusinessDecisionAnswerReceiptState", "BusinessDecisionAnswerReceiptV1", "BusinessDecisionRequiredAuthn", "BusinessDecisionTaskV1", + "business_decision_presentation_text_digest", "sign_business_decision_answer_hmac", "sign_business_decision_answer_receipt_hmac", "sign_business_decision_delivery_policy_hmac", "sign_business_decision_task_hmac", "validate_business_decision_answer", + "validate_business_decision_delivery", # episode "Episode", "Step", @@ -286,6 +301,7 @@ "ExecuteAcceptedV1", "ExecuteAuthorizationContextV1", "ExecuteDecisionRequiredWebhookV1", + "ExecuteDecisionTaskV1", "ExecuteEvidenceContractV1", "ExecuteEvidenceReceiptV1", "ExecuteLifecycleStateV1", diff --git a/openadapt_types/business_decision.py b/openadapt_types/business_decision.py index be22041..0d926fd 100644 --- a/openadapt_types/business_decision.py +++ b/openadapt_types/business_decision.py @@ -12,10 +12,12 @@ application state, and pass the successor action's normal identity and effect contracts before it actuates. -No question text, option label, screenshot, OCR output, record value, or live -identifier belongs in this wire format. A consumer renders reviewed static -copy from the exact ``presentation_digest``. A decision that needs protected -context is ``local_answer_required`` and cannot be answered by a remote client. +The task and answer envelopes contain no question text, option label, +screenshot, OCR output, record value, or live identifier. A separate +presentation artifact carries reviewed static copy. Each text field is either +local-only or bound to a positive egress review. A remote projection refuses +local-only or unreviewed copy. A decision that needs protected context is +``local_answer_required`` and cannot be answered by a remote client. """ from __future__ import annotations @@ -50,6 +52,7 @@ _ANSWER_DOMAIN = b"openadapt.business-decision-answer/v1\x00" _DELIVERY_POLICY_DOMAIN = b"openadapt.business-decision-delivery-policy/v1\x00" _ANSWER_RECEIPT_DOMAIN = b"openadapt.business-decision-answer-receipt/v1\x00" +_PRESENTATION_TEXT_DOMAIN = b"openadapt.business-decision-presentation-text/v1\x00" _OPAQUE_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$" _OPTION_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" _IDEMPOTENCY_KEY_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,199}$" @@ -89,23 +92,97 @@ class BusinessDecisionOptionBindingV1(_StrictContract): target_binding_digest: StrictStr = Field(pattern=_SHA256_PATTERN) -class BusinessDecisionPresentationOptionV1(_StrictContract): - """One exact option label from the reviewed workflow contract.""" +class BusinessDecisionPresentationClassification(str, Enum): + """The reviewed egress class for one static presentation field.""" - option_id: StrictStr = Field(pattern=_OPTION_ID_PATTERN) - label: StrictStr = Field( + LOCAL_ONLY = "local_only" + REVIEWED_REMOTE_SAFE = "reviewed_remote_safe" + + +class BusinessDecisionContextKind(str, Enum): + """Closed kinds for reviewed institutional context.""" + + POLICY = "policy" + PRECEDENT = "precedent" + RELATIONSHIP = "relationship" + CAPACITY = "capacity" + TIMING = "timing" + RISK = "risk" + OTHER_REVIEWED = "other_reviewed" + + +class BusinessDecisionJudgmentReason(str, Enum): + """Finite reasons why the qualified workflow retains human authority.""" + + INSTITUTIONAL_KNOWLEDGE_REQUIRED = "institutional_knowledge_required" + POLICY_EXCEPTION = "policy_exception" + COMPETING_PRIORITIES = "competing_priorities" + RELATIONSHIP_CONTEXT = "relationship_context" + CAPACITY_CONSTRAINT = "capacity_constraint" + TEMPORAL_CONTEXT = "temporal_context" + RISK_ACCEPTANCE = "risk_acceptance" + OTHER_REVIEWED = "other_reviewed" + + +class BusinessDecisionPresentationTextV1(_StrictContract): + """One static text field and its positive egress-review binding.""" + + text: StrictStr = Field( min_length=1, - max_length=120, + max_length=500, pattern=_STATIC_PRESENTATION_TEXT_PATTERN, ) + classification: BusinessDecisionPresentationClassification + egress_review_digest: StrictStr | None = Field( + default=None, + pattern=_SHA256_PATTERN, + ) + + @model_validator(mode="after") + def _validate_egress_review(self) -> BusinessDecisionPresentationTextV1: + if self.text.strip() != self.text: + raise ValueError("business decision presentation text must be trimmed") + if ( + self.classification + is BusinessDecisionPresentationClassification.REVIEWED_REMOTE_SAFE + ) != (self.egress_review_digest is not None): + raise ValueError( + "reviewed remote-safe text requires one egress review digest; " + "local-only text cannot carry one" + ) + return self + + @property + def content_digest(self) -> str: + """Return the canonical digest for the exact reviewed copy.""" + + return business_decision_presentation_text_digest(self.text) + + +class BusinessDecisionPresentationOptionV1(_StrictContract): + """One exact option label from the reviewed workflow contract.""" + + option_id: StrictStr = Field(pattern=_OPTION_ID_PATTERN) + label: BusinessDecisionPresentationTextV1 + detail: BusinessDecisionPresentationTextV1 | None = None + consequence: BusinessDecisionPresentationTextV1 | None = None + + +class BusinessDecisionContextCardV1(_StrictContract): + """One reviewed static context item; no runtime observation belongs here.""" + + context_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + kind: BusinessDecisionContextKind + label: BusinessDecisionPresentationTextV1 + value: BusinessDecisionPresentationTextV1 class BusinessDecisionPresentationV1(_StrictContract): - """Reviewed static copy for one exact qualified business decision. + """Reviewed presentation artifact for one business decision. - This artifact is separate from the Cloud-safe task because it contains - human-readable text. The qualification path must review it before remote - use. The signed task carries only this artifact's digest and opaque ref. + This artifact is separate from the Cloud-safe task. The signed delivery + policy binds its exact digest and the positive egress review before a + remote route can show it. """ schema_version: Literal["openadapt.business-decision-presentation/v1"] = ( @@ -115,32 +192,86 @@ class BusinessDecisionPresentationV1(_StrictContract): presentation_revision: StrictInt = Field(ge=1, le=2_147_483_647) decision_contract_digest: StrictStr = Field(pattern=_SHA256_PATTERN) decision_contract_revision: StrictInt = Field(ge=1, le=2_147_483_647) - question: StrictStr = Field( - min_length=1, - max_length=500, - pattern=_STATIC_PRESENTATION_TEXT_PATTERN, + category: BusinessDecisionPresentationTextV1 | None = None + title: BusinessDecisionPresentationTextV1 | None = None + role_label: BusinessDecisionPresentationTextV1 | None = None + question: BusinessDecisionPresentationTextV1 + why_judgment_needed: BusinessDecisionPresentationTextV1 | None = None + context_cards: tuple[BusinessDecisionContextCardV1, ...] = Field( + default=(), + max_length=16, ) options: tuple[BusinessDecisionPresentationOptionV1, ...] = Field( min_length=2, max_length=32, ) + reason_codes: tuple[BusinessDecisionJudgmentReason, ...] = Field( + default=(), + max_length=8, + ) review_contract_digest: StrictStr = Field(pattern=_SHA256_PATTERN) - reviewed_safe_for_remote: Literal[True] = True @model_validator(mode="after") def _validate_presentation(self) -> BusinessDecisionPresentationV1: - if self.question.strip() != self.question: - raise ValueError("business decision question must be trimmed") option_ids = tuple(option.option_id for option in self.options) - labels = tuple(option.label for option in self.options) + labels = tuple(option.label.text for option in self.options) if len(option_ids) != len(set(option_ids)): raise ValueError("business decision presentation option ids must be unique") - if any(label.strip() != label for label in labels): - raise ValueError("business decision presentation labels must be trimmed") if len({label.casefold() for label in labels}) != len(labels): raise ValueError("business decision presentation labels must be unique") + if any(len(label) > 120 for label in labels): + raise ValueError( + "business decision presentation option labels exceed 120 characters" + ) + context_ids = tuple(card.context_id for card in self.context_cards) + if len(context_ids) != len(set(context_ids)): + raise ValueError("business decision context ids must be unique") + if len(self.reason_codes) != len(set(self.reason_codes)): + raise ValueError("business decision reason codes must be unique") return self + def text_fields(self) -> tuple[BusinessDecisionPresentationTextV1, ...]: + """Return every classified text leaf in deterministic field order.""" + + fields = [ + field + for field in ( + self.category, + self.title, + self.role_label, + self.question, + self.why_judgment_needed, + ) + if field is not None + ] + for card in self.context_cards: + fields.extend((card.label, card.value)) + for option in self.options: + fields.append(option.label) + if option.detail is not None: + fields.append(option.detail) + if option.consequence is not None: + fields.append(option.consequence) + return tuple(fields) + + @property + def egress_review_digests(self) -> frozenset[str]: + """Return all positive review bindings in this presentation.""" + + values = [field.egress_review_digest for field in self.text_fields()] + return frozenset(value for value in values if value is not None) + + @property + def remote_safe(self) -> bool: + """Return true only when every text field has a positive egress review.""" + + return all( + field.classification + is BusinessDecisionPresentationClassification.REVIEWED_REMOTE_SAFE + and field.egress_review_digest is not None + for field in self.text_fields() + ) + def canonical_bytes(self) -> bytes: return _canonical_json(self.model_dump(mode="json")) @@ -161,6 +292,10 @@ class BusinessDecisionDeliveryPolicyV1(_StrictContract): decision_contract_revision: StrictInt = Field(ge=1, le=2_147_483_647) presentation_ref: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) presentation_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + presentation_egress_review_digest: StrictStr | None = Field( + default=None, + pattern=_SHA256_PATTERN, + ) authorized_role_refs: tuple[_OpaqueRoleRef, ...] = Field( min_length=1, max_length=16 ) @@ -198,6 +333,13 @@ def _validate_policy(self) -> BusinessDecisionDeliveryPolicyV1: self.created_at, "created_at" ): raise ValueError("expires_at must be after created_at") + if ( + self.delivery_mode is BusinessDecisionDeliveryMode.REMOTE_ANSWERABLE + ) != (self.presentation_egress_review_digest is not None): + raise ValueError( + "remote delivery requires one presentation egress review digest; " + "local-only delivery cannot carry one" + ) return self def unsigned_payload(self) -> dict[str, Any]: @@ -550,6 +692,130 @@ def validate_business_decision_answer( raise ValueError("the business decision answer is from the future") +def validate_business_decision_delivery( + task: BusinessDecisionTaskV1, + presentation: BusinessDecisionPresentationV1, + policy: BusinessDecisionDeliveryPolicyV1, + *, + task_signing_key: bytes, + qualification_signing_key: bytes, + at: str, +) -> None: + """Authenticate one task and its exact reviewed presentation manifest. + + This check authenticates only the portable delivery bindings. A remote + consumer can render only copy that has the exact positive egress-review + binding in the signed policy. This check does not authorize execution and + does not prove a business effect. + """ + + if not task.verify_hmac(task_signing_key): + raise ValueError("the business decision task signature is invalid") + if not policy.verify_hmac(qualification_signing_key): + raise ValueError("the business decision delivery policy signature is invalid") + + at_time = _parse_timestamp(at, "at") + policy_created_at = _parse_timestamp(policy.created_at, "policy.created_at") + policy_expires_at = _parse_timestamp(policy.expires_at, "policy.expires_at") + task_created_at = _parse_timestamp(task.created_at, "task.created_at") + task_expires_at = _parse_timestamp(task.expires_at, "task.expires_at") + if at_time < policy_created_at or at_time >= policy_expires_at: + raise ValueError("the business decision delivery policy is not active") + if at_time < task_created_at or at_time >= task_expires_at: + raise ValueError("the business decision task is not active") + if task_created_at < policy_created_at or task_expires_at > policy_expires_at: + raise ValueError("the business decision task exceeds the delivery policy") + if policy.delivery_mode is BusinessDecisionDeliveryMode.REMOTE_ANSWERABLE: + if not presentation.remote_safe: + raise ValueError( + "remote business decision presentation contains local-only text" + ) + if presentation.egress_review_digests != { + policy.presentation_egress_review_digest + }: + raise ValueError( + "remote business decision presentation review binding does not match" + ) + + expected = { + "delivery_policy_digest": (task.delivery_policy_digest, policy.digest), + "presentation_ref": (task.presentation_ref, presentation.presentation_ref), + "presentation_digest": (task.presentation_digest, presentation.digest), + "policy.presentation_ref": ( + policy.presentation_ref, + presentation.presentation_ref, + ), + "policy.presentation_digest": ( + policy.presentation_digest, + presentation.digest, + ), + "decision_contract_digest": ( + task.decision_contract_digest, + presentation.decision_contract_digest, + ), + "decision_contract_revision": ( + task.decision_contract_revision, + presentation.decision_contract_revision, + ), + "policy.decision_contract_digest": ( + policy.decision_contract_digest, + presentation.decision_contract_digest, + ), + "policy.decision_contract_revision": ( + policy.decision_contract_revision, + presentation.decision_contract_revision, + ), + "authorized_role_refs": ( + task.authorized_role_refs, + policy.authorized_role_refs, + ), + "authorized_route_refs": ( + task.authorized_route_refs, + policy.authorized_route_refs, + ), + "authorized_answer_issuer_key_ids": ( + task.authorized_answer_issuer_key_ids, + policy.authorized_answer_issuer_key_ids, + ), + "role_mapping_digest": ( + task.role_mapping_digest, + policy.role_mapping_digest, + ), + "required_authn": (task.required_authn, policy.required_authn), + "delivery_mode": (task.delivery_mode, policy.delivery_mode), + "relay_capability_digest": ( + task.relay_capability_digest, + policy.relay_capability_digest, + ), + } + for name, (actual, required) in expected.items(): + if actual != required: + raise ValueError(f"business decision {name} does not match") + + task_option_ids = tuple(option.option_id for option in task.options) + presentation_option_ids = tuple(option.option_id for option in presentation.options) + if task_option_ids != presentation_option_ids: + raise ValueError("business decision presentation options do not match the task") + + +def business_decision_presentation_text_digest(text: str) -> str: + """Return a domain-separated digest for exact static presentation copy.""" + + if not isinstance(text, str): + raise TypeError("business decision presentation text must be a string") + if not 1 <= len(text) <= 500 or text.strip() != text: + raise ValueError( + "business decision presentation text must be trimmed and 1-500 characters" + ) + if any(ord(character) < 32 or ord(character) == 127 for character in text): + raise ValueError( + "business decision presentation text contains a control character" + ) + return "sha256:" + hashlib.sha256( + _PRESENTATION_TEXT_DOMAIN + text.encode("utf-8") + ).hexdigest() + + def sign_business_decision_task_hmac( *, key: bytes, fields: Mapping[str, Any] ) -> BusinessDecisionTaskV1: diff --git a/openadapt_types/execute.py b/openadapt_types/execute.py index 2e5b0f0..11707ad 100644 --- a/openadapt_types/execute.py +++ b/openadapt_types/execute.py @@ -16,7 +16,7 @@ import hmac import json from enum import Enum -from typing import Any, Literal, Mapping, TypeAlias +from typing import Annotated, Any, Literal, Mapping, TypeAlias from pydantic import ( BaseModel, @@ -29,6 +29,7 @@ model_validator, ) +from openadapt_types.business_decision import BusinessDecisionTaskV1 from openadapt_types.human_decision import HumanDecisionTaskV1 EXECUTE_REQUEST_SCHEMA = "openadapt.execute-request/v1" @@ -246,12 +247,18 @@ class ExecuteTerminalWebhookV1(_SignedExecuteWebhookV1): receipt: ExecuteEvidenceReceiptV1 +ExecuteDecisionTaskV1: TypeAlias = Annotated[ + HumanDecisionTaskV1 | BusinessDecisionTaskV1, + Field(discriminator="schema_version"), +] + + class ExecuteDecisionRequiredWebhookV1(_SignedExecuteWebhookV1): event_type: Literal[ExecuteWebhookEventTypeV1.DECISION_REQUIRED] = ( ExecuteWebhookEventTypeV1.DECISION_REQUIRED ) execution_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) - decision: HumanDecisionTaskV1 + decision: ExecuteDecisionTaskV1 ExecuteWebhookV1: TypeAlias = ( diff --git a/openadapt_types/schemas/business-decision-delivery-policy-v1.json b/openadapt_types/schemas/business-decision-delivery-policy-v1.json index 6c9b5c4..891eece 100644 --- a/openadapt_types/schemas/business-decision-delivery-policy-v1.json +++ b/openadapt_types/schemas/business-decision-delivery-policy-v1.json @@ -102,6 +102,19 @@ "title": "Presentation Digest", "type": "string" }, + "presentation_egress_review_digest": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Presentation Egress Review Digest" + }, "presentation_ref": { "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", "title": "Presentation Ref", diff --git a/openadapt_types/schemas/business-decision-presentation-v1.json b/openadapt_types/schemas/business-decision-presentation-v1.json index d3e07d7..0c75102 100644 --- a/openadapt_types/schemas/business-decision-presentation-v1.json +++ b/openadapt_types/schemas/business-decision-presentation-v1.json @@ -1,15 +1,99 @@ { "$defs": { + "BusinessDecisionContextCardV1": { + "additionalProperties": false, + "description": "One reviewed static context item; no runtime observation belongs here.", + "properties": { + "context_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Context Id", + "type": "string" + }, + "kind": { + "$ref": "#/$defs/BusinessDecisionContextKind" + }, + "label": { + "$ref": "#/$defs/BusinessDecisionPresentationTextV1" + }, + "value": { + "$ref": "#/$defs/BusinessDecisionPresentationTextV1" + } + }, + "required": [ + "context_id", + "kind", + "label", + "value" + ], + "title": "BusinessDecisionContextCardV1", + "type": "object" + }, + "BusinessDecisionContextKind": { + "description": "Closed kinds for reviewed institutional context.", + "enum": [ + "policy", + "precedent", + "relationship", + "capacity", + "timing", + "risk", + "other_reviewed" + ], + "title": "BusinessDecisionContextKind", + "type": "string" + }, + "BusinessDecisionJudgmentReason": { + "description": "Finite reasons why the qualified workflow retains human authority.", + "enum": [ + "institutional_knowledge_required", + "policy_exception", + "competing_priorities", + "relationship_context", + "capacity_constraint", + "temporal_context", + "risk_acceptance", + "other_reviewed" + ], + "title": "BusinessDecisionJudgmentReason", + "type": "string" + }, + "BusinessDecisionPresentationClassification": { + "description": "The reviewed egress class for one static presentation field.", + "enum": [ + "local_only", + "reviewed_remote_safe" + ], + "title": "BusinessDecisionPresentationClassification", + "type": "string" + }, "BusinessDecisionPresentationOptionV1": { "additionalProperties": false, "description": "One exact option label from the reviewed workflow contract.", "properties": { + "consequence": { + "anyOf": [ + { + "$ref": "#/$defs/BusinessDecisionPresentationTextV1" + }, + { + "type": "null" + } + ], + "default": null + }, + "detail": { + "anyOf": [ + { + "$ref": "#/$defs/BusinessDecisionPresentationTextV1" + }, + { + "type": "null" + } + ], + "default": null + }, "label": { - "maxLength": 120, - "minLength": 1, - "pattern": "^[^\\x00-\\x1f\\x7f]{1,500}$", - "title": "Label", - "type": "string" + "$ref": "#/$defs/BusinessDecisionPresentationTextV1" }, "option_id": { "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", @@ -23,11 +107,66 @@ ], "title": "BusinessDecisionPresentationOptionV1", "type": "object" + }, + "BusinessDecisionPresentationTextV1": { + "additionalProperties": false, + "description": "One static text field and its positive egress-review binding.", + "properties": { + "classification": { + "$ref": "#/$defs/BusinessDecisionPresentationClassification" + }, + "egress_review_digest": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Egress Review Digest" + }, + "text": { + "maxLength": 500, + "minLength": 1, + "pattern": "^[^\\x00-\\x1f\\x7f]{1,500}$", + "title": "Text", + "type": "string" + } + }, + "required": [ + "text", + "classification" + ], + "title": "BusinessDecisionPresentationTextV1", + "type": "object" } }, "additionalProperties": false, - "description": "Reviewed static copy for one exact qualified business decision.\n\nThis artifact is separate from the Cloud-safe task because it contains\nhuman-readable text. The qualification path must review it before remote\nuse. The signed task carries only this artifact's digest and opaque ref.", + "description": "Reviewed presentation artifact for one business decision.\n\nThis artifact is separate from the Cloud-safe task. The signed delivery\npolicy binds its exact digest and the positive egress review before a\nremote route can show it.", "properties": { + "category": { + "anyOf": [ + { + "$ref": "#/$defs/BusinessDecisionPresentationTextV1" + }, + { + "type": "null" + } + ], + "default": null + }, + "context_cards": { + "default": [], + "items": { + "$ref": "#/$defs/BusinessDecisionContextCardV1" + }, + "maxItems": 16, + "title": "Context Cards", + "type": "array" + }, "decision_contract_digest": { "pattern": "^sha256:[0-9a-f]{64}$", "title": "Decision Contract Digest", @@ -60,28 +199,60 @@ "type": "integer" }, "question": { - "maxLength": 500, - "minLength": 1, - "pattern": "^[^\\x00-\\x1f\\x7f]{1,500}$", - "title": "Question", - "type": "string" + "$ref": "#/$defs/BusinessDecisionPresentationTextV1" + }, + "reason_codes": { + "default": [], + "items": { + "$ref": "#/$defs/BusinessDecisionJudgmentReason" + }, + "maxItems": 8, + "title": "Reason Codes", + "type": "array" }, "review_contract_digest": { "pattern": "^sha256:[0-9a-f]{64}$", "title": "Review Contract Digest", "type": "string" }, - "reviewed_safe_for_remote": { - "const": true, - "default": true, - "title": "Reviewed Safe For Remote", - "type": "boolean" + "role_label": { + "anyOf": [ + { + "$ref": "#/$defs/BusinessDecisionPresentationTextV1" + }, + { + "type": "null" + } + ], + "default": null }, "schema_version": { "const": "openadapt.business-decision-presentation/v1", "default": "openadapt.business-decision-presentation/v1", "title": "Schema Version", "type": "string" + }, + "title": { + "anyOf": [ + { + "$ref": "#/$defs/BusinessDecisionPresentationTextV1" + }, + { + "type": "null" + } + ], + "default": null + }, + "why_judgment_needed": { + "anyOf": [ + { + "$ref": "#/$defs/BusinessDecisionPresentationTextV1" + }, + { + "type": "null" + } + ], + "default": null } }, "required": [ diff --git a/openadapt_types/schemas/execute-v1-openapi.json b/openadapt_types/schemas/execute-v1-openapi.json index fa58876..64c24b3 100644 --- a/openadapt_types/schemas/execute-v1-openapi.json +++ b/openadapt_types/schemas/execute-v1-openapi.json @@ -1,6 +1,321 @@ { "components": { "schemas": { + "BusinessDecisionDeliveryMode": { + "description": "Whether the decision can leave the customer-controlled context.", + "enum": [ + "remote_answerable", + "local_answer_required" + ], + "title": "BusinessDecisionDeliveryMode", + "type": "string" + }, + "BusinessDecisionOptionBindingV1": { + "additionalProperties": false, + "description": "One finite option and its opaque compiled-successor commitment.", + "properties": { + "option_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "title": "Option Id", + "type": "string" + }, + "target_binding_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Target Binding Digest", + "type": "string" + } + }, + "required": [ + "option_id", + "target_binding_digest" + ], + "title": "BusinessDecisionOptionBindingV1", + "type": "object" + }, + "BusinessDecisionRequiredAuthn": { + "description": "The exact authentication profile required by the qualified contract.", + "enum": [ + "local_enterprise_identity", + "aal2", + "webauthn" + ], + "title": "BusinessDecisionRequiredAuthn", + "type": "string" + }, + "BusinessDecisionTaskV1": { + "additionalProperties": false, + "description": "A signed remote-safe projection of one exact business-decision pause.", + "properties": { + "authorized_answer_issuer_key_ids": { + "items": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "title": "Authorized Answer Issuer Key Ids", + "type": "array" + }, + "authorized_role_refs": { + "items": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "title": "Authorized Role Refs", + "type": "array" + }, + "authorized_route_refs": { + "items": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "title": "Authorized Route Refs", + "type": "array" + }, + "bundle_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Bundle Digest", + "type": "string" + }, + "control_frames_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Control Frames Digest", + "type": "string" + }, + "created_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Created At", + "type": "string" + }, + "decision_contract_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Decision Contract Digest", + "type": "string" + }, + "decision_contract_revision": { + "maximum": 2147483647, + "minimum": 1, + "title": "Decision Contract Revision", + "type": "integer" + }, + "delivery_mode": { + "$ref": "#/components/schemas/BusinessDecisionDeliveryMode" + }, + "delivery_policy_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Delivery Policy Digest", + "type": "string" + }, + "expires_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Expires At", + "type": "string" + }, + "governed_runtime_inputs_digest": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Governed Runtime Inputs Digest" + }, + "idempotency_scope_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Idempotency Scope Digest", + "type": "string" + }, + "issuer_key_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Issuer Key Id", + "type": "string" + }, + "local_evidence_required": { + "title": "Local Evidence Required", + "type": "boolean" + }, + "options": { + "items": { + "$ref": "#/components/schemas/BusinessDecisionOptionBindingV1" + }, + "maxItems": 32, + "minItems": 2, + "title": "Options", + "type": "array" + }, + "pause_binding_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Pause Binding Digest", + "type": "string" + }, + "pause_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Pause Id", + "type": "string" + }, + "presentation_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Presentation Digest", + "type": "string" + }, + "presentation_ref": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Presentation Ref", + "type": "string" + }, + "program_scope_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Program Scope Digest", + "type": "string" + }, + "relay_capability_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Relay Capability Digest", + "type": "string" + }, + "request_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Request Digest", + "type": "string" + }, + "request_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Request Id", + "type": "string" + }, + "request_revision": { + "default": 1, + "maximum": 2147483647, + "minimum": 1, + "title": "Request Revision", + "type": "integer" + }, + "required_authn": { + "$ref": "#/components/schemas/BusinessDecisionRequiredAuthn" + }, + "required_evidence_count": { + "default": 0, + "maximum": 64, + "minimum": 0, + "title": "Required Evidence Count", + "type": "integer" + }, + "role_mapping_digest": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Role Mapping Digest", + "type": "string" + }, + "run_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Run Id", + "type": "string" + }, + "runner_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Runner Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.business-decision-task/v1", + "default": "openadapt.business-decision-task/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Signature", + "type": "string" + }, + "signature_algorithm": { + "const": "hmac-sha256", + "default": "hmac-sha256", + "title": "Signature Algorithm", + "type": "string" + }, + "supersedes_request_digest": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Supersedes Request Digest" + }, + "task_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Task Id", + "type": "string" + }, + "task_revision": { + "default": 1, + "maximum": 2147483647, + "minimum": 1, + "title": "Task Revision", + "type": "integer" + }, + "tenant_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Tenant Id", + "type": "string" + }, + "workflow_contract_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Workflow Contract Digest", + "type": "string" + } + }, + "required": [ + "task_id", + "tenant_id", + "runner_id", + "run_id", + "pause_id", + "pause_binding_digest", + "request_id", + "request_digest", + "bundle_digest", + "workflow_contract_digest", + "decision_contract_digest", + "decision_contract_revision", + "delivery_policy_digest", + "program_scope_digest", + "control_frames_digest", + "presentation_ref", + "presentation_digest", + "options", + "authorized_role_refs", + "authorized_route_refs", + "authorized_answer_issuer_key_ids", + "role_mapping_digest", + "required_authn", + "delivery_mode", + "local_evidence_required", + "relay_capability_digest", + "idempotency_scope_digest", + "created_at", + "expires_at", + "issuer_key_id", + "signature" + ], + "title": "BusinessDecisionTaskV1", + "type": "object" + }, "EffectStrengthV1": { "description": "The required strength of proof for a consequential effect.\n\nThe members are named so a future protocol version can add a strength\nwithout changing the meaning of a numeric value.", "enum": [ @@ -65,7 +380,22 @@ "additionalProperties": false, "properties": { "decision": { - "$ref": "#/components/schemas/HumanDecisionTaskV1" + "discriminator": { + "mapping": { + "openadapt.business-decision-task/v1": "#/components/schemas/BusinessDecisionTaskV1", + "openadapt.human-decision-task/v1": "#/components/schemas/HumanDecisionTaskV1" + }, + "propertyName": "schema_version" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/HumanDecisionTaskV1" + }, + { + "$ref": "#/components/schemas/BusinessDecisionTaskV1" + } + ], + "title": "Decision" }, "delivery_attempt": { "maximum": 100, diff --git a/tests/test_business_decision.py b/tests/test_business_decision.py index d44c15d..810fbee 100644 --- a/tests/test_business_decision.py +++ b/tests/test_business_decision.py @@ -14,11 +14,25 @@ BusinessDecisionDeliveryPolicyV1, BusinessDecisionPresentationV1, BusinessDecisionTaskV1, + ExecuteDecisionRequiredWebhookV1, + ExecuteWebhookEventTypeV1, + HumanDecisionAction, + HumanDecisionDeliveryState, + HumanDecisionEvidenceSummaryV1, + HumanDecisionQuestionTemplate, + HumanDecisionQuestionV1, + HumanDecisionRequiredAuthn, + HumanDecisionSafeSlotsV1, + HumanDecisionTaskKind, + HumanDecisionTaskV1, sign_business_decision_answer_hmac, sign_business_decision_answer_receipt_hmac, sign_business_decision_delivery_policy_hmac, sign_business_decision_task_hmac, + sign_execute_webhook_hmac, + sign_human_decision_task_hmac, validate_business_decision_answer, + validate_business_decision_delivery, ) KEY = b"k" * 32 @@ -90,6 +104,29 @@ def _answer_fields(task: BusinessDecisionTaskV1) -> dict[str, object]: } +def _operational_task_fields() -> dict[str, object]: + return { + "task_id": "task_operational_01", + "run_id": "run_operational_001", + "pause_id": "pause_operational_01", + "capability_digest": _digest("1"), + "bundle_digest": _digest("2"), + "task_kind": HumanDecisionTaskKind.IDENTITY, + "delivery_state": HumanDecisionDeliveryState.NOT_DELIVERED, + "question": HumanDecisionQuestionV1( + template=HumanDecisionQuestionTemplate.CONFIRM_IDENTITY, + safe_slots=HumanDecisionSafeSlotsV1(required_signal_count=2), + ), + "evidence": HumanDecisionEvidenceSummaryV1(identity_required_count=2), + "allowed_actions": (HumanDecisionAction.VERIFY_AND_RESUME,), + "required_authn": HumanDecisionRequiredAuthn.LOCAL_SESSION, + "created_at": "2026-08-08T12:00:00Z", + "expires_at": "2026-08-08T12:05:00Z", + "nonce": "nonce_operational_01", + "issuer_key_id": "runner_signing_01", + } + + def test_signed_task_binds_the_complete_remote_authority_context() -> None: task = sign_business_decision_task_hmac(key=KEY, fields=_task_fields()) assert task.verify_hmac(KEY) @@ -256,16 +293,82 @@ def test_runner_receipt_never_reports_business_success() -> None: def test_delivery_policy_binds_reviewed_copy_and_remote_authority() -> None: + review_digest = _digest("9") presentation = BusinessDecisionPresentationV1( presentation_ref="present_12345678", presentation_revision=1, decision_contract_digest=_digest("7"), decision_contract_revision=4, - question="Which reviewed path should continue?", + category={ + "text": "Schedule exception", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + title={ + "text": "Choose the reviewed path", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + role_label={ + "text": "Scheduling lead", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + question={ + "text": "Which reviewed path should continue?", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + why_judgment_needed={ + "text": "The local policy leaves this exception to the scheduling lead.", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + context_cards=( + { + "context_id": "context_policy_01", + "kind": "policy", + "label": { + "text": "Policy", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + "value": { + "text": "Use the approved exception path.", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + }, + ), options=( - {"option_id": "approve", "label": "Approve"}, - {"option_id": "decline", "label": "Decline"}, + { + "option_id": "approve", + "label": { + "text": "Approve", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + "detail": { + "text": "Continue to the qualified exception branch.", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + "consequence": { + "text": "The runner will revalidate the live state before action.", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + }, + { + "option_id": "decline", + "label": { + "text": "Decline", + "classification": "reviewed_remote_safe", + "egress_review_digest": review_digest, + }, + }, ), + reason_codes=("institutional_knowledge_required", "policy_exception"), review_contract_digest=_digest("8"), ) policy = sign_business_decision_delivery_policy_hmac( @@ -277,7 +380,8 @@ def test_delivery_policy_binds_reviewed_copy_and_remote_authority() -> None: "decision_contract_revision": presentation.decision_contract_revision, "presentation_ref": presentation.presentation_ref, "presentation_digest": presentation.digest, - "authorized_role_refs": ("role_approver_01",), + "presentation_egress_review_digest": review_digest, + "authorized_role_refs": ("role_approver_01", "role_reviewer_01"), "authorized_route_refs": ("route_mobile_001",), "authorized_answer_issuer_key_ids": ("cloud_signing_001",), "role_mapping_digest": "hmac-sha256:" + "d" * 64, @@ -292,6 +396,40 @@ def test_delivery_policy_binds_reviewed_copy_and_remote_authority() -> None: assert policy.verify_hmac(KEY) assert policy.presentation_digest == presentation.digest + task = sign_business_decision_task_hmac( + key=KEY, + fields={ + **_task_fields(), + "delivery_policy_digest": policy.digest, + "presentation_digest": presentation.digest, + }, + ) + validate_business_decision_delivery( + task, + presentation, + policy, + task_signing_key=KEY, + qualification_signing_key=KEY, + at="2026-08-08T12:01:00Z", + ) + + changed_presentation = presentation.model_copy( + update={ + "question": presentation.question.model_copy( + update={"text": "Which other reviewed path should continue?"} + ) + } + ) + with pytest.raises(ValueError, match="presentation_digest"): + validate_business_decision_delivery( + task, + changed_presentation, + policy, + task_signing_key=KEY, + qualification_signing_key=KEY, + at="2026-08-08T12:01:00Z", + ) + def test_short_answer_idempotency_key_is_refused() -> None: task = sign_business_decision_task_hmac(key=KEY, fields=_task_fields()) @@ -360,12 +498,15 @@ def visit(node: object, path: str) -> None: [ (BusinessDecisionTaskV1, "business-decision-task-v1.json"), (BusinessDecisionAnswerV1, "business-decision-answer-v1.json"), - (BusinessDecisionPresentationV1, "business-decision-presentation-v1.json"), (BusinessDecisionDeliveryPolicyV1, "business-decision-delivery-policy-v1.json"), ], ) -def test_cloud_safe_contract_has_no_free_text_or_sensitive_extension( - model: type[BusinessDecisionTaskV1 | BusinessDecisionAnswerV1], +def test_portable_contract_has_no_free_text_or_sensitive_extension( + model: type[ + BusinessDecisionTaskV1 + | BusinessDecisionAnswerV1 + | BusinessDecisionDeliveryPolicyV1 + ], filename: str, ) -> None: schema = model.model_json_schema() @@ -391,6 +532,88 @@ def test_unknown_live_context_fields_are_structurally_refused() -> None: BusinessDecisionTaskV1.model_validate({**payload, field: value}) +def test_remote_delivery_refuses_unreviewed_arbitrary_human_text() -> None: + base = { + "presentation_ref": "present_12345678", + "presentation_revision": 1, + "decision_contract_digest": _digest("7"), + "decision_contract_revision": 4, + "question": { + "text": "Approve live patient John Smith, MRN 123456?", + "classification": "local_only", + }, + "options": ( + { + "option_id": "approve", + "label": { + "text": "Approve John Smith", + "classification": "local_only", + }, + }, + { + "option_id": "decline", + "label": { + "text": "Decline", + "classification": "local_only", + }, + }, + ), + "review_contract_digest": _digest("8"), + } + presentation = BusinessDecisionPresentationV1.model_validate(base) + review_digest = _digest("9") + policy = sign_business_decision_delivery_policy_hmac( + key=KEY, + fields={ + "policy_ref": "policy_12345678", + "policy_revision": 1, + "decision_contract_digest": presentation.decision_contract_digest, + "decision_contract_revision": presentation.decision_contract_revision, + "presentation_ref": presentation.presentation_ref, + "presentation_digest": presentation.digest, + "presentation_egress_review_digest": review_digest, + "authorized_role_refs": ("role_approver_01", "role_reviewer_01"), + "authorized_route_refs": ("route_mobile_001",), + "authorized_answer_issuer_key_ids": ("cloud_signing_001",), + "role_mapping_digest": "hmac-sha256:" + "d" * 64, + "required_authn": "aal2", + "delivery_mode": "remote_answerable", + "relay_capability_digest": _digest("e"), + "created_at": "2026-08-08T12:00:00Z", + "expires_at": "2026-08-08T12:05:00Z", + "issuer_key_id": "qualification_key_01", + }, + ) + task = sign_business_decision_task_hmac( + key=KEY, + fields={ + **_task_fields(), + "delivery_policy_digest": policy.digest, + "presentation_digest": presentation.digest, + }, + ) + with pytest.raises(ValueError, match="local-only text"): + validate_business_decision_delivery( + task, + presentation, + policy, + task_signing_key=KEY, + qualification_signing_key=KEY, + at="2026-08-08T12:01:00Z", + ) + + with pytest.raises(ValidationError, match="egress review digest"): + BusinessDecisionPresentationV1.model_validate( + { + **base, + "question": { + "text": "Approve live patient John Smith, MRN 123456?", + "classification": "reviewed_remote_safe", + }, + } + ) + + def test_option_ids_are_finite_and_unique() -> None: with pytest.raises(ValidationError, match="option ids must be unique"): sign_business_decision_task_hmac( @@ -416,3 +639,49 @@ def test_option_ids_are_finite_and_unique() -> None: key=KEY, fields={**_task_fields(), "options": too_many}, ) + + +def test_execute_decision_webhook_preserves_operational_and_business_tasks() -> None: + operational = sign_human_decision_task_hmac( + key=KEY, + fields=_operational_task_fields(), + ) + operational_webhook = sign_execute_webhook_hmac( + key=KEY, + fields={ + "event_type": ExecuteWebhookEventTypeV1.DECISION_REQUIRED, + "event_id": "event_operational_01", + "delivery_attempt": 1, + "issued_at": "2026-08-08T12:00:01Z", + "issuer_key_id": "webhook_signing_01", + "execution_id": "execution_operational_01", + "decision": operational.model_dump(mode="json"), + }, + ) + assert isinstance(operational_webhook.decision, HumanDecisionTaskV1) + assert operational_webhook.decision == operational + + business = sign_business_decision_task_hmac(key=KEY, fields=_task_fields()) + business_webhook = sign_execute_webhook_hmac( + key=KEY, + fields={ + "event_type": ExecuteWebhookEventTypeV1.DECISION_REQUIRED, + "event_id": "event_business_0001", + "delivery_attempt": 1, + "issued_at": "2026-08-08T12:00:01Z", + "issuer_key_id": "webhook_signing_01", + "execution_id": "execution_business_0001", + "decision": business.model_dump(mode="json"), + }, + ) + reparsed = ExecuteDecisionRequiredWebhookV1.model_validate_json( + business_webhook.model_dump_json() + ) + assert isinstance(reparsed.decision, BusinessDecisionTaskV1) + assert reparsed.decision == business + assert reparsed.verify_hmac(KEY) + + schema = json.dumps(ExecuteDecisionRequiredWebhookV1.model_json_schema()) + assert "BusinessDecisionTaskV1" in schema + assert "HumanDecisionTaskV1" in schema + assert '"propertyName": "schema_version"' in schema