diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index 1cfc3d0..5ae870c 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -81,18 +81,25 @@ from openadapt_types.episode import Episode, Step from openadapt_types.failure import FailureCategory, FailureRecord from openadapt_types.human_decision import ( + HUMAN_DECISION_RECEIPT_REASONS, + HUMAN_DECISION_RECEIPT_SCHEMA, + HUMAN_DECISION_RECEIPT_SUCCESS_STATES, HUMAN_DECISION_TASK_SCHEMA, HumanDecisionAction, HumanDecisionDeliveryState, HumanDecisionEvidenceSummaryV1, HumanDecisionQuestionTemplate, HumanDecisionQuestionV1, + HumanDecisionReceiptReason, + HumanDecisionReceiptState, + HumanDecisionReceiptV1, HumanDecisionRequiredAuthn, HumanDecisionRiskClass, HumanDecisionSafeSlotsV1, HumanDecisionSubstrate, HumanDecisionTaskKind, HumanDecisionTaskV1, + sign_human_decision_receipt_hmac, sign_human_decision_task_hmac, ) from openadapt_types.parsing import ( @@ -166,18 +173,25 @@ "FailureCategory", "FailureRecord", # attended human decisions + "HUMAN_DECISION_RECEIPT_REASONS", + "HUMAN_DECISION_RECEIPT_SCHEMA", + "HUMAN_DECISION_RECEIPT_SUCCESS_STATES", "HUMAN_DECISION_TASK_SCHEMA", "HumanDecisionAction", "HumanDecisionDeliveryState", "HumanDecisionEvidenceSummaryV1", "HumanDecisionQuestionTemplate", "HumanDecisionQuestionV1", + "HumanDecisionReceiptReason", + "HumanDecisionReceiptState", + "HumanDecisionReceiptV1", "HumanDecisionRequiredAuthn", "HumanDecisionRiskClass", "HumanDecisionSafeSlotsV1", "HumanDecisionSubstrate", "HumanDecisionTaskKind", "HumanDecisionTaskV1", + "sign_human_decision_receipt_hmac", "sign_human_decision_task_hmac", # parsing "from_benchmark_action", diff --git a/openadapt_types/human_decision.py b/openadapt_types/human_decision.py index 3ba37d8..1ffaa6a 100644 --- a/openadapt_types/human_decision.py +++ b/openadapt_types/human_decision.py @@ -1,26 +1,44 @@ -"""Privacy-safe, signed tasks for attended human decisions. +"""Privacy-safe, signed contracts for attended human decisions. -The task is a portable projection of an existing runtime pause. It is not an -execution capability: a consumer must still present the runtime's separately -issued capability, satisfy its authentication policy, and pass fresh -revalidation before actuation. Free-form text, screenshots, identifiers, and -observed values deliberately do not belong in this contract. +Two models close one round trip: + +* :class:`HumanDecisionTaskV1` — the question. A portable projection of an + existing runtime pause. It is not an execution capability: a consumer must + still present the runtime's separately issued capability, satisfy its + authentication policy, and pass fresh revalidation before actuation. +* :class:`HumanDecisionReceiptV1` — the answer's terminal outcome. The only + decision result that may cross to a phone, a tray, or an authenticated + remote relay. + +Free-form text, screenshots, identifiers, and observed values deliberately do +not belong in either contract. Cloud-safe by construction -------------------------- -There is exactly one task model and it is the Cloud-safe one; there is no -"local extension" variant that a producer could accidentally relay. Every -string-typed field is a ``Literal``, an ``Enum`` member, or carries an explicit -``pattern``, and every model forbids unknown fields. Raw values, OCR text, -screenshots, and operator prose therefore have no field to travel in, and a new -field cannot quietly open one: ``tests/test_human_decision.py`` walks the -exported JSON Schema and fails on any string that is not closed by a pattern, -``const``, or ``enum``. +There is exactly one task model and exactly one receipt model, and both are the +Cloud-safe ones; there is no "local extension" variant that a producer could +accidentally relay. Every string-typed field is a ``Literal``, an ``Enum`` +member, or carries an explicit ``pattern``, and every model forbids unknown +fields. Raw values, OCR text, screenshots, and operator prose therefore have no +field to travel in, and a new field cannot quietly open one: +``tests/test_human_decision.py`` walks both exported JSON Schemas and fails on +any string that is not closed by a pattern, ``const``, or ``enum``. + +The receipt matters most here. A runtime's own decision record is an +append-only audit artifact that carries operator prose and an operator +principal on purpose (Flow's ``AttendedDecision`` has both). A producer must +therefore *rebuild* a receipt rather than redact its audit record field by +field: strip-on-send stays correct only until someone adds a field, whereas a +closed target type has no such failure mode. ``reason_code`` is a closed enum +precisely so a consumer renders deterministic copy instead of relaying a +runtime's free-text message. Signature canonicalization (normative) -------------------------------------- -An HMAC over this task must be reproducible byte-for-byte in any language, so -the encoding is fixed rather than left to a JSON library's defaults: +An HMAC over either contract must be reproducible byte-for-byte in any +language, so the encoding is fixed rather than left to a JSON library's +defaults. The rules below are written for the task; the receipt uses the same +rules verbatim, with its own domain separator: 1. Start from ``unsigned_payload()``: the model serialized in JSON mode with ``signature`` removed. Every other field is always present, including those @@ -37,8 +55,11 @@ 5. Encode the result as UTF-8. 6. Prepend the domain separator ``b"openadapt.human-decision-task/v1\\x00"`` before computing the HMAC, so a signature over this contract can never be - replayed as a signature over a different OpenAdapt payload. The digest in - :attr:`HumanDecisionTaskV1.digest` is taken over the canonical bytes + replayed as a signature over a different OpenAdapt payload. The receipt's + separator is ``b"openadapt.human-decision-receipt/v1\\x00"``, so a task + signature can never be replayed as a receipt signature either. The digests + in :attr:`HumanDecisionTaskV1.digest` and + :attr:`HumanDecisionReceiptV1.digest` are taken over the canonical bytes *without* the domain separator. Only integers and booleans appear as non-string scalars, and every integer is @@ -49,9 +70,10 @@ the signed bytes. ``tests/test_human_decision.py`` pins the exact canonical bytes and the exact -signature hex of a fixed task. Any change to field names, field order handling, -escaping, or the domain separator will fail that vector, which is the intended -signal to cut a new schema version rather than to silently re-sign. +signature hex of one fixed task and one fixed receipt. Any change to field +names, field order handling, escaping, or a domain separator will fail those +vectors, which is the intended signal to cut a new schema version rather than +to silently re-sign. """ from __future__ import annotations @@ -74,7 +96,9 @@ ) HUMAN_DECISION_TASK_SCHEMA = "openadapt.human-decision-task/v1" +HUMAN_DECISION_RECEIPT_SCHEMA = "openadapt.human-decision-receipt/v1" _SIGNING_DOMAIN = b"openadapt.human-decision-task/v1\x00" +_RECEIPT_SIGNING_DOMAIN = b"openadapt.human-decision-receipt/v1\x00" _OPAQUE_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$" _SHA256_PATTERN = r"^sha256:[0-9a-f]{64}$" _SIGNATURE_PATTERN = r"^hmac-sha256:[0-9a-f]{64}$" @@ -294,7 +318,7 @@ def verify_hmac(self, key: bytes) -> bool: """Verify the task signature without granting execution authority.""" _validate_hmac_key(key) - expected = _hmac_signature(key, self.unsigned_payload()) + expected = _hmac_signature(key, self.unsigned_payload(), _SIGNING_DOMAIN) return hmac.compare_digest(expected, self.signature) @@ -303,8 +327,15 @@ def _validate_hmac_key(key: bytes) -> None: raise ValueError("human decision HMAC key must contain at least 32 bytes") -def _hmac_signature(key: bytes, payload: Mapping[str, Any]) -> str: - digest = hmac.new(key, _SIGNING_DOMAIN + _canonical_json(payload), hashlib.sha256) +def _hmac_signature(key: bytes, payload: Mapping[str, Any], domain: bytes) -> str: + """Sign one canonical payload under an explicit, contract-specific domain. + + ``domain`` is a required positional argument rather than a default so that + adding a third contract cannot silently reuse the task's separator and make + one contract's signature replayable as another's. + """ + + digest = hmac.new(key, domain + _canonical_json(payload), hashlib.sha256) return "hmac-sha256:" + digest.hexdigest() @@ -323,5 +354,212 @@ def sign_human_decision_task_hmac( {**unsigned, "signature": "hmac-sha256:" + "0" * 64} ) canonical_unsigned = validated.unsigned_payload() - signature = _hmac_signature(key, canonical_unsigned) + signature = _hmac_signature(key, canonical_unsigned, _SIGNING_DOMAIN) + return validated.model_copy(update={"signature": signature}) + + +class HumanDecisionReceiptState(str, Enum): + """Terminal outcome of one attended decision, as the runtime reports it. + + ``ACCEPTED_PENDING_RUNNER`` and ``DELIVERY_UNCERTAIN`` are the two + non-success outcomes a phone must be able to show. ``DELIVERY_UNCERTAIN`` + is the "may have been sent" state: it exists so that losing the network + after a tap can be reported honestly instead of collapsing into either a + success or a refusal. + + ``DEMONSTRATION_REQUESTED`` and ``ESCALATED`` are separate terminal states + rather than variants of ``ACCEPTED_PENDING_RUNNER``: a ``teach`` or + ``escalate`` decision is durably recorded and returns immediately without + any runner continuation pending, so folding them into "accepted, pending" + would tell an operator to wait for something that is never coming. + """ + + ACCEPTED_PENDING_RUNNER = "accepted_pending_runner" + COMPLETED = "completed" + REFUSED = "refused" + HALTED = "halted" + EXPIRED = "expired" + DELIVERY_UNCERTAIN = "delivery_uncertain" + DEMONSTRATION_REQUESTED = "demonstration_requested" + ESCALATED = "escalated" + + +class HumanDecisionReceiptReason(str, Enum): + """Closed cause for a terminal state; the consumer renders copy from it. + + This is the field that would otherwise be a free-text ``message``. It is a + closed enum, never an arbitrary string, so a runtime cannot relay operator + prose, an exception message, an observed value, or a record identifier + through the receipt's explanation. + """ + + PENDING_RUNNER = "pending_runner" + VERIFIED_AND_RESUMED = "verified_and_resumed" + SKIPPED_AND_RESUMED = "skipped_and_resumed" + CONTINUATION_HALTED = "continuation_halted" + REVALIDATION_REFUSED = "revalidation_refused" + EXPIRED = "expired" + DELIVERY_UNCERTAIN = "delivery_uncertain" + DEMONSTRATION_REQUESTED = "demonstration_requested" + ESCALATION_RECORDED = "escalation_recorded" + + +#: Which causes each terminal state may carry. +#: +#: ``state`` and ``reason_code`` are separate fields because one state can have +#: more than one cause -- ``completed`` is reached both by resuming after +#: verification and by resuming after a skip, and a consumer must be able to +#: tell those apart without parsing prose. They are not independent, though: an +#: unconstrained pair would let a producer emit ``completed`` with reason +#: ``expired``, so the permitted combinations are pinned here and enforced by a +#: model validator. +HUMAN_DECISION_RECEIPT_REASONS: Mapping[ + HumanDecisionReceiptState, frozenset[HumanDecisionReceiptReason] +] = { + HumanDecisionReceiptState.ACCEPTED_PENDING_RUNNER: frozenset( + {HumanDecisionReceiptReason.PENDING_RUNNER} + ), + HumanDecisionReceiptState.COMPLETED: frozenset( + { + HumanDecisionReceiptReason.VERIFIED_AND_RESUMED, + HumanDecisionReceiptReason.SKIPPED_AND_RESUMED, + } + ), + HumanDecisionReceiptState.REFUSED: frozenset( + {HumanDecisionReceiptReason.REVALIDATION_REFUSED} + ), + HumanDecisionReceiptState.HALTED: frozenset( + {HumanDecisionReceiptReason.CONTINUATION_HALTED} + ), + HumanDecisionReceiptState.EXPIRED: frozenset({HumanDecisionReceiptReason.EXPIRED}), + HumanDecisionReceiptState.DELIVERY_UNCERTAIN: frozenset( + {HumanDecisionReceiptReason.DELIVERY_UNCERTAIN} + ), + HumanDecisionReceiptState.DEMONSTRATION_REQUESTED: frozenset( + {HumanDecisionReceiptReason.DEMONSTRATION_REQUESTED} + ), + HumanDecisionReceiptState.ESCALATED: frozenset( + {HumanDecisionReceiptReason.ESCALATION_RECORDED} + ), +} + +#: States that report a *successful* effect on the workflow. Everything else is +#: a non-success outcome, so a consumer that wants "did this work?" reads this +#: set rather than inferring from the absence of an error string. +HUMAN_DECISION_RECEIPT_SUCCESS_STATES: frozenset[HumanDecisionReceiptState] = frozenset( + {HumanDecisionReceiptState.COMPLETED} +) + + +class HumanDecisionReceiptV1(_StrictContract): + """The closed, PHI-free terminal outcome of one attended decision. + + This is the only decision result that may cross to a phone, a tray, or an + authenticated remote relay. It is a rebuilt value, not a filtered copy of + a runtime's audit record: there is no free-text field, no operator + identity, no workflow or step label, no parameter, no path, no observed + value, and no evidence. Protected content is structurally unrepresentable + rather than stripped on send, so a later field addition on the producer + side cannot reopen a hole here. + + ``action`` reuses :class:`HumanDecisionAction`, the same portable + vocabulary the task advertises in ``allowed_actions``, so a consumer + compares the receipt against the task it answered without translating an + engine-internal name. + + The digests are one-way commitments, not content: they let a consumer bind + this receipt to the exact capability, request, decision record, and + transition receipt it came from without ever receiving those payloads. + + ``signature`` is optional because a runtime that returns a receipt over an + authenticated loopback connection is already inside its own trust boundary + and does not sign. A receipt that arrives over a network must be signed, + and its consumer must verify it: see :meth:`verify_hmac`. + """ + + schema_version: Literal["openadapt.human-decision-receipt/v1"] = ( + HUMAN_DECISION_RECEIPT_SCHEMA + ) + task_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + task_revision: StrictInt = Field(default=1, ge=1) + pause_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + capability_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + request_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + decision_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + transition_receipt_digest: StrictStr | None = Field( + default=None, pattern=_SHA256_PATTERN + ) + action: HumanDecisionAction + state: HumanDecisionReceiptState + reason_code: HumanDecisionReceiptReason + report_success: StrictBool | None = None + decided_at: StrictStr = Field( + min_length=20, max_length=40, pattern=_TIMESTAMP_PATTERN + ) + signature_algorithm: Literal["hmac-sha256"] = "hmac-sha256" + signature: StrictStr | None = Field(default=None, pattern=_SIGNATURE_PATTERN) + + @model_validator(mode="after") + def _validate_outcome(self) -> "HumanDecisionReceiptV1": + permitted = HUMAN_DECISION_RECEIPT_REASONS[self.state] + if self.reason_code not in permitted: + raise ValueError( + f"reason_code {self.reason_code.value!r} is not a cause of state " + f"{self.state.value!r}" + ) + if ( + self.report_success + and self.state not in HUMAN_DECISION_RECEIPT_SUCCESS_STATES + ): + raise ValueError( + f"report_success cannot be true for state {self.state.value!r}" + ) + _parse_timestamp(self.decided_at, "decided_at") + return self + + @property + def succeeded(self) -> bool: + """Whether this receipt reports a successful workflow continuation.""" + + return self.state in HUMAN_DECISION_RECEIPT_SUCCESS_STATES + + def unsigned_payload(self) -> dict[str, Any]: + """Return the deterministic language-agnostic signed payload.""" + + 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 "sha256:" + hashlib.sha256(self.canonical_unsigned_bytes()).hexdigest() + + def verify_hmac(self, key: bytes) -> bool: + """Verify the receipt signature. An unsigned receipt never verifies.""" + + _validate_hmac_key(key) + if self.signature is None: + return False + expected = _hmac_signature( + key, self.unsigned_payload(), _RECEIPT_SIGNING_DOMAIN + ) + return hmac.compare_digest(expected, self.signature) + + +def sign_human_decision_receipt_hmac( + *, key: bytes, fields: Mapping[str, Any] +) -> HumanDecisionReceiptV1: + """Validate and sign a receipt using the local-v1 HMAC profile.""" + + _validate_hmac_key(key) + if "signature" in fields: + raise ValueError("fields must not contain a signature") + unsigned = dict(fields) + unsigned.setdefault("schema_version", HUMAN_DECISION_RECEIPT_SCHEMA) + unsigned.setdefault("signature_algorithm", "hmac-sha256") + validated = HumanDecisionReceiptV1.model_validate(unsigned) + signature = _hmac_signature( + key, validated.unsigned_payload(), _RECEIPT_SIGNING_DOMAIN + ) return validated.model_copy(update={"signature": signature}) diff --git a/openadapt_types/schemas/human-decision-receipt-v1.json b/openadapt_types/schemas/human-decision-receipt-v1.json new file mode 100644 index 0000000..807a074 --- /dev/null +++ b/openadapt_types/schemas/human-decision-receipt-v1.json @@ -0,0 +1,160 @@ +{ + "$defs": { + "HumanDecisionAction": { + "description": "Portable action names; the runtime remains authoritative.\n\n``verify_and_resume`` maps to Flow's attended ``continue`` operation. It\nmeans that the operator prepared the live state for fresh revalidation; it\nnever authorizes blind repetition of a prior action.", + "enum": [ + "verify_and_resume", + "skip", + "teach", + "escalate" + ], + "title": "HumanDecisionAction", + "type": "string" + }, + "HumanDecisionReceiptReason": { + "description": "Closed cause for a terminal state; the consumer renders copy from it.\n\nThis is the field that would otherwise be a free-text ``message``. It is a\nclosed enum, never an arbitrary string, so a runtime cannot relay operator\nprose, an exception message, an observed value, or a record identifier\nthrough the receipt's explanation.", + "enum": [ + "pending_runner", + "verified_and_resumed", + "skipped_and_resumed", + "continuation_halted", + "revalidation_refused", + "expired", + "delivery_uncertain", + "demonstration_requested", + "escalation_recorded" + ], + "title": "HumanDecisionReceiptReason", + "type": "string" + }, + "HumanDecisionReceiptState": { + "description": "Terminal outcome of one attended decision, as the runtime reports it.\n\n``ACCEPTED_PENDING_RUNNER`` and ``DELIVERY_UNCERTAIN`` are the two\nnon-success outcomes a phone must be able to show. ``DELIVERY_UNCERTAIN``\nis the \"may have been sent\" state: it exists so that losing the network\nafter a tap can be reported honestly instead of collapsing into either a\nsuccess or a refusal.\n\n``DEMONSTRATION_REQUESTED`` and ``ESCALATED`` are separate terminal states\nrather than variants of ``ACCEPTED_PENDING_RUNNER``: a ``teach`` or\n``escalate`` decision is durably recorded and returns immediately without\nany runner continuation pending, so folding them into \"accepted, pending\"\nwould tell an operator to wait for something that is never coming.", + "enum": [ + "accepted_pending_runner", + "completed", + "refused", + "halted", + "expired", + "delivery_uncertain", + "demonstration_requested", + "escalated" + ], + "title": "HumanDecisionReceiptState", + "type": "string" + } + }, + "additionalProperties": false, + "description": "The closed, PHI-free terminal outcome of one attended decision.\n\nThis is the only decision result that may cross to a phone, a tray, or an\nauthenticated remote relay. It is a rebuilt value, not a filtered copy of\na runtime's audit record: there is no free-text field, no operator\nidentity, no workflow or step label, no parameter, no path, no observed\nvalue, and no evidence. Protected content is structurally unrepresentable\nrather than stripped on send, so a later field addition on the producer\nside cannot reopen a hole here.\n\n``action`` reuses :class:`HumanDecisionAction`, the same portable\nvocabulary the task advertises in ``allowed_actions``, so a consumer\ncompares the receipt against the task it answered without translating an\nengine-internal name.\n\nThe digests are one-way commitments, not content: they let a consumer bind\nthis receipt to the exact capability, request, decision record, and\ntransition receipt it came from without ever receiving those payloads.\n\n``signature`` is optional because a runtime that returns a receipt over an\nauthenticated loopback connection is already inside its own trust boundary\nand does not sign. A receipt that arrives over a network must be signed,\nand its consumer must verify it: see :meth:`verify_hmac`.", + "properties": { + "action": { + "$ref": "#/$defs/HumanDecisionAction" + }, + "capability_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Capability 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,9})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Decided At", + "type": "string" + }, + "decision_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Decision Digest", + "type": "string" + }, + "pause_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Pause Id", + "type": "string" + }, + "reason_code": { + "$ref": "#/$defs/HumanDecisionReceiptReason" + }, + "report_success": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Report Success" + }, + "request_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Request Digest", + "type": "string" + }, + "schema_version": { + "const": "openadapt.human-decision-receipt/v1", + "default": "openadapt.human-decision-receipt/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "anyOf": [ + { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Signature" + }, + "signature_algorithm": { + "const": "hmac-sha256", + "default": "hmac-sha256", + "title": "Signature Algorithm", + "type": "string" + }, + "state": { + "$ref": "#/$defs/HumanDecisionReceiptState" + }, + "task_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Task Id", + "type": "string" + }, + "task_revision": { + "default": 1, + "minimum": 1, + "title": "Task Revision", + "type": "integer" + }, + "transition_receipt_digest": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transition Receipt Digest" + } + }, + "required": [ + "task_id", + "pause_id", + "capability_digest", + "request_digest", + "decision_digest", + "action", + "state", + "reason_code", + "decided_at" + ], + "title": "HumanDecisionReceiptV1", + "type": "object" +} diff --git a/scripts/export_control_overlay_schemas.py b/scripts/export_control_overlay_schemas.py index f42177f..49a7d12 100644 --- a/scripts/export_control_overlay_schemas.py +++ b/scripts/export_control_overlay_schemas.py @@ -10,6 +10,7 @@ ControlOverlayFrameV2, ControlOverlayTimelineV1, ControlOverlayTimelineV2, + HumanDecisionReceiptV1, HumanDecisionTaskV1, ) @@ -21,6 +22,7 @@ "control-overlay-frame-v2.json": ControlOverlayFrameV2, "control-overlay-timeline-v2.json": ControlOverlayTimelineV2, "human-decision-task-v1.json": HumanDecisionTaskV1, + "human-decision-receipt-v1.json": HumanDecisionReceiptV1, } diff --git a/tests/test_human_decision.py b/tests/test_human_decision.py index d16f49a..65280cb 100644 --- a/tests/test_human_decision.py +++ b/tests/test_human_decision.py @@ -1,5 +1,7 @@ """Durable tests for the attended human-decision wire contract.""" +import hashlib +import hmac import json from importlib.resources import files @@ -7,15 +9,21 @@ from pydantic import ValidationError from openadapt_types import ( + HUMAN_DECISION_RECEIPT_REASONS, + HUMAN_DECISION_RECEIPT_SUCCESS_STATES, HumanDecisionAction, HumanDecisionDeliveryState, HumanDecisionEvidenceSummaryV1, HumanDecisionQuestionTemplate, HumanDecisionQuestionV1, + HumanDecisionReceiptReason, + HumanDecisionReceiptState, + HumanDecisionReceiptV1, HumanDecisionRequiredAuthn, HumanDecisionSafeSlotsV1, HumanDecisionTaskKind, HumanDecisionTaskV1, + sign_human_decision_receipt_hmac, sign_human_decision_task_hmac, ) @@ -149,13 +157,11 @@ def test_cloud_safe_task_rejects_each_forbidden_category(category: str) -> None: HumanDecisionTaskV1.model_validate({**base, "evidence": nested_evidence}) -def test_no_declared_string_field_accepts_free_text() -> None: - """The exported schema is the contract a non-Python consumer validates. +def unconstrained_string_paths(schema: object) -> list[str]: + """Walk an exported JSON Schema and report every string left open. - Closing unknown fields is not sufficient on its own: a *declared* string - with no pattern would let protected content travel inside an accepted - field. Every string in the contract must therefore be closed by a pattern, - a ``const``, or an ``enum``. + Shared by the task and the receipt so both contracts are held to the same + structural rule and neither can drift away from it independently. """ unconstrained: list[str] = [] @@ -171,8 +177,19 @@ def visit(node: object, path: str) -> None: for index, value in enumerate(node): visit(value, f"{path}/{index}") - visit(HumanDecisionTaskV1.model_json_schema(), "") - assert unconstrained == [] + visit(schema, "") + return unconstrained + + +def test_no_declared_string_field_accepts_free_text() -> None: + """The exported schema is the contract a non-Python consumer validates. + + Closing unknown fields is not sufficient on its own: a *declared* string + with no pattern would let protected content travel inside an accepted + field. Every string in the contract must therefore be closed by a pattern, + a ``const``, or an ``enum``. + """ + assert unconstrained_string_paths(HumanDecisionTaskV1.model_json_schema()) == [] @pytest.mark.parametrize( @@ -265,3 +282,435 @@ def test_delivery_uncertainty_is_a_first_class_value() -> None: fields={**_task_fields(), "delivery_state": HumanDecisionDeliveryState.UNKNOWN}, ) assert uncertain.model_dump(mode="json")["delivery_state"] == "unknown" + + +# ── HumanDecisionReceiptV1 ─────────────────────────────────────────── +# +# The receipt closes the round trip the task opened. The runtime's own +# decision record is an append-only audit artifact that carries a free-text +# message and the operator principal on purpose; neither may reach a phone or +# Cloud. Every test below asserts that the receipt cannot carry them at all, +# rather than that some producer removed them on the way out. + + +def _receipt_fields() -> dict[str, object]: + return { + "task_id": "task_12345678", + "pause_id": "pause_1234567", + "capability_digest": "sha256:" + "a" * 64, + "request_digest": "sha256:" + "c" * 64, + "decision_digest": "sha256:" + "d" * 64, + "transition_receipt_digest": "sha256:" + "e" * 64, + "action": HumanDecisionAction.VERIFY_AND_RESUME, + "state": HumanDecisionReceiptState.COMPLETED, + "reason_code": HumanDecisionReceiptReason.VERIFIED_AND_RESUMED, + "report_success": True, + "decided_at": "2026-07-26T12:03:00Z", + } + + +#: Every terminal outcome the runtime engine really produces, in the exact +#: (state, reason_code) pairing the receipt must accept. ``expired`` is +#: reachable through admission rather than through an engine decision record, +#: and is included because a consumer still has to render it. +ENGINE_TERMINAL_OUTCOMES: tuple[tuple[str, str, str], ...] = ( + ("prepared", "accepted_pending_runner", "pending_runner"), + ("delivery_started", "delivery_uncertain", "delivery_uncertain"), + ("delivery_uncertain", "delivery_uncertain", "delivery_uncertain"), + ("completed", "completed", "verified_and_resumed"), + ("completed/skip", "completed", "skipped_and_resumed"), + ("refused", "refused", "revalidation_refused"), + ("halted", "halted", "continuation_halted"), + ("needs_demonstration", "demonstration_requested", "demonstration_requested"), + ("escalated", "escalated", "escalation_recorded"), + ("", "expired", "expired"), +) + + +@pytest.mark.parametrize( + ("engine_status", "state", "reason_code"), ENGINE_TERMINAL_OUTCOMES +) +def test_receipt_represents_every_real_engine_terminal_outcome( + engine_status: str, state: str, reason_code: str +) -> None: + """No real terminal outcome has to be collapsed into a different one. + + ``demonstration_requested`` and ``escalated`` are separate states because + a ``teach`` or ``escalate`` decision is recorded and returned immediately + with no runner continuation pending; reporting them as + ``accepted_pending_runner`` would tell the operator to wait for something + that is never coming. + """ + del engine_status + receipt = HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "state": state, + "reason_code": reason_code, + "report_success": state == "completed", + "action": ( + "skip" if reason_code == "skipped_and_resumed" else "verify_and_resume" + ), + } + ) + assert receipt.state.value == state + assert receipt.reason_code.value == reason_code + assert receipt.succeeded is (state == "completed") + + +def test_receipt_reason_code_is_closed_and_never_free_text() -> None: + """``reason_code`` is the field that would otherwise be a message.""" + assert {reason.value for reason in HumanDecisionReceiptReason} == { + "pending_runner", + "verified_and_resumed", + "skipped_and_resumed", + "continuation_halted", + "revalidation_refused", + "expired", + "delivery_uncertain", + "demonstration_requested", + "escalation_recorded", + } + + for prose in ( + "resumed after the operator fixed the coverage row", + "Patient J. Doe: claim 88213 adjudicated", + "", + "verified_and_resumed ", + ): + with pytest.raises(ValidationError): + HumanDecisionReceiptV1.model_validate( + {**_receipt_fields(), "reason_code": prose} + ) + + schema = HumanDecisionReceiptV1.model_json_schema() + reason_schema = schema["$defs"]["HumanDecisionReceiptReason"] + assert set(reason_schema["enum"]) == { + reason.value for reason in HumanDecisionReceiptReason + } + assert "pattern" not in reason_schema + + +def test_receipt_has_no_free_text_field_at_all() -> None: + """Enumerated per field, so adding an open string fails here immediately.""" + schema = HumanDecisionReceiptV1.model_json_schema() + assert schema["additionalProperties"] is False + assert unconstrained_string_paths(schema) == [] + + # The two protected fields the runtime's audit record carries by design. + assert "message" not in schema["properties"] + assert "operator" not in schema["properties"] + for forbidden in ("screenshot", "ocr", "patient", "mrn"): + assert forbidden not in json.dumps(schema).lower() + + +@pytest.mark.parametrize("category", sorted(FORBIDDEN_EVIDENCE_FIELDS)) +def test_receipt_rejects_each_forbidden_category(category: str) -> None: + """The same enumerated categories the task refuses, refused structurally.""" + base = HumanDecisionReceiptV1.model_validate(_receipt_fields()).model_dump( + mode="json" + ) + for field, value in FORBIDDEN_EVIDENCE_FIELDS[category].items(): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + HumanDecisionReceiptV1.model_validate({**base, field: value}) + + +@pytest.mark.parametrize( + "value", + [ + "J DOE MRN 0093211 XY", + "2026-07-26 patient jd", + "coverage active as of", + "2026-07-26T12:00:00", + ], +) +def test_receipt_timestamps_cannot_smuggle_text_or_drop_their_offset( + value: str, +) -> None: + """``decided_at`` carries the same RFC 3339 pattern 0.6.1 established. + + The pattern lives on the field, so it reaches the exported schema and a + non-Python consumer inherits it rather than only a length bound. + """ + with pytest.raises(ValidationError): + HumanDecisionReceiptV1.model_validate( + {**_receipt_fields(), "decided_at": value} + ) + + exported = HumanDecisionReceiptV1.model_json_schema()["properties"]["decided_at"] + assert exported["pattern"] == ( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$" + ) + + +def test_receipt_delivery_uncertainty_is_a_first_class_value() -> None: + """ "May have been sent" must be statable as a terminal outcome. + + The acceptance criterion is that losing the network after a tap shows + pending/uncertain and never success. A receipt that could not express + uncertainty would force the producer to pick success or refusal, so the + three-state vocabulary is asserted on both the task and the receipt. + """ + assert {state.value for state in HumanDecisionDeliveryState} == { + "not_delivered", + "delivered", + "unknown", + } + + uncertain = HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "state": HumanDecisionReceiptState.DELIVERY_UNCERTAIN, + "reason_code": HumanDecisionReceiptReason.DELIVERY_UNCERTAIN, + "report_success": None, + "transition_receipt_digest": None, + } + ) + assert uncertain.model_dump(mode="json")["state"] == "delivery_uncertain" + assert uncertain.succeeded is False + + pending = HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "state": HumanDecisionReceiptState.ACCEPTED_PENDING_RUNNER, + "reason_code": HumanDecisionReceiptReason.PENDING_RUNNER, + "report_success": None, + } + ) + assert pending.succeeded is False + + for required in ("state", "reason_code"): + fields = _receipt_fields() + del fields[required] + with pytest.raises(ValidationError, match=required): + HumanDecisionReceiptV1.model_validate(fields) + + +def test_receipt_state_and_reason_code_cannot_be_paired_arbitrarily() -> None: + """Two fields, but not two independent fields. + + They are split because one state has more than one cause: ``completed`` is + reached both by resuming after verification and by resuming after a skip, + and a consumer must tell those apart without parsing prose. Leaving the + pair unconstrained would let a producer emit ``completed`` with reason + ``expired``, so the permitted combinations are pinned. + """ + assert set(HUMAN_DECISION_RECEIPT_REASONS) == set(HumanDecisionReceiptState) + covered = set().union(*HUMAN_DECISION_RECEIPT_REASONS.values()) + assert covered == set(HumanDecisionReceiptReason) + + for state, reasons in HUMAN_DECISION_RECEIPT_REASONS.items(): + for reason in HumanDecisionReceiptReason: + fields = { + **_receipt_fields(), + "state": state, + "reason_code": reason, + "report_success": None, + } + if reason in reasons: + assert HumanDecisionReceiptV1.model_validate(fields).state is state + else: + with pytest.raises(ValidationError, match="is not a cause of state"): + HumanDecisionReceiptV1.model_validate(fields) + + +def test_receipt_cannot_report_success_outside_a_success_state() -> None: + """A non-success terminal outcome can never carry ``report_success``. + + "Runnable is not certified": an uncertain delivery, a halt, or a refusal + must not be renderable as a success by a consumer that only reads the + boolean. + """ + assert HUMAN_DECISION_RECEIPT_SUCCESS_STATES == frozenset( + {HumanDecisionReceiptState.COMPLETED} + ) + for state in HumanDecisionReceiptState: + reason = sorted(HUMAN_DECISION_RECEIPT_REASONS[state], key=lambda r: r.value)[0] + fields = { + **_receipt_fields(), + "state": state, + "reason_code": reason, + "report_success": True, + } + if state in HUMAN_DECISION_RECEIPT_SUCCESS_STATES: + assert HumanDecisionReceiptV1.model_validate(fields).report_success is True + else: + with pytest.raises(ValidationError, match="report_success cannot be true"): + HumanDecisionReceiptV1.model_validate(fields) + + +def test_receipt_action_reuses_the_portable_task_vocabulary() -> None: + """A consumer compares the receipt against the task without translating. + + The engine's internal ``continue`` has no representation here; the only + action names are the ones a task advertises in ``allowed_actions``. + """ + receipt_action = HumanDecisionReceiptV1.model_json_schema()["properties"]["action"] + assert receipt_action["$ref"] == "#/$defs/HumanDecisionAction" + assert {action.value for action in HumanDecisionAction} == { + "verify_and_resume", + "skip", + "teach", + "escalate", + } + with pytest.raises(ValidationError): + HumanDecisionReceiptV1.model_validate( + {**_receipt_fields(), "action": "continue"} + ) + + +def test_packaged_receipt_schema_matches_the_exported_contract() -> None: + schema = HumanDecisionReceiptV1.model_json_schema() + packaged = files("openadapt_types.schemas").joinpath( + "human-decision-receipt-v1.json" + ) + assert json.loads(packaged.read_text()) == schema + + +#: One frozen vector pinning the receipt canonicalization, exactly as the task +#: contract does. A cross-language implementation is correct when it reproduces +#: these bytes, this digest, and this signature hex. +RECEIPT_VECTOR_BYTES = ( + b'{"action":"verify_and_resume",' + b'"capability_digest":"sha256:' + b"a" * 64 + b'",' + b'"decided_at":"2026-07-26T12:03:00Z",' + b'"decision_digest":"sha256:' + b"d" * 64 + b'",' + b'"pause_id":"pause_1234567","reason_code":"verified_and_resumed",' + b'"report_success":true,' + b'"request_digest":"sha256:' + b"c" * 64 + b'",' + b'"schema_version":"openadapt.human-decision-receipt/v1",' + b'"signature_algorithm":"hmac-sha256","state":"completed",' + b'"task_id":"task_12345678","task_revision":1,' + b'"transition_receipt_digest":"sha256:' + b"e" * 64 + b'"}' +) +RECEIPT_VECTOR_DIGEST = ( + "sha256:1fc191fc298e155c305a744e816bf58c09ac07a68b9096d13f34aa0eba6797c6" +) +RECEIPT_VECTOR_SIGNATURE = ( + "hmac-sha256:385e7802b4c1ec8ff557f4c15e8b1e8c63d87fbffb08b9d61df27c59e9c44c34" +) + + +def test_receipt_canonical_bytes_and_signature_are_pinned_for_other_languages() -> None: + receipt = sign_human_decision_receipt_hmac(key=b"k" * 32, fields=_receipt_fields()) + + assert receipt.canonical_unsigned_bytes() == RECEIPT_VECTOR_BYTES + assert receipt.digest == RECEIPT_VECTOR_DIGEST + assert receipt.signature == RECEIPT_VECTOR_SIGNATURE + assert receipt.verify_hmac(b"k" * 32) + + +def test_receipt_canonicalization_matches_the_documented_rules() -> None: + """Assert the properties a reimplementation has to match, not just bytes.""" + canonical = sign_human_decision_receipt_hmac( + key=b"k" * 32, fields=_receipt_fields() + ).canonical_unsigned_bytes() + + assert canonical.decode("ascii") + assert b", " not in canonical and b": " not in canonical + payload = json.loads(canonical) + assert list(payload) == sorted(payload) + assert "signature" not in payload + # Optional fields are present with an explicit null, never omitted. + nullable = json.loads( + sign_human_decision_receipt_hmac( + key=b"k" * 32, + fields={ + **_receipt_fields(), + "transition_receipt_digest": None, + "report_success": None, + }, + ).canonical_unsigned_bytes() + ) + assert nullable["transition_receipt_digest"] is None + assert nullable["report_success"] is None + + +def test_receipt_signature_is_domain_separated_from_the_task_signature() -> None: + """A task signature can never be replayed as a receipt signature.""" + key = b"k" * 32 + receipt = sign_human_decision_receipt_hmac(key=key, fields=_receipt_fields()) + + assert receipt.verify_hmac(key) + assert not receipt.verify_hmac(b"j" * 32) + + tampered = receipt.model_copy(update={"state": HumanDecisionReceiptState.REFUSED}) + assert not tampered.verify_hmac(key) + + # Same canonical payload, task domain instead of the receipt domain. + task_domain_signature = ( + "hmac-sha256:" + + hmac.new( + key, + b"openadapt.human-decision-task/v1\x00" + + receipt.canonical_unsigned_bytes(), + hashlib.sha256, + ).hexdigest() + ) + assert task_domain_signature != receipt.signature + assert not receipt.model_copy( + update={"signature": task_domain_signature} + ).verify_hmac(key) + + +def test_unsigned_receipt_never_verifies() -> None: + """The local loopback receipt is unsigned; it must not pass as signed.""" + unsigned = HumanDecisionReceiptV1.model_validate(_receipt_fields()) + assert unsigned.signature is None + assert not unsigned.verify_hmac(b"k" * 32) + + with pytest.raises(ValueError, match="at least 32 bytes"): + unsigned.verify_hmac(b"short") + with pytest.raises(ValueError, match="must not contain a signature"): + sign_human_decision_receipt_hmac( + key=b"k" * 32, + fields={**_receipt_fields(), "signature": "hmac-sha256:" + "0" * 64}, + ) + + +#: The exact JSON body ``openadapt-flow``'s console decision route returns +#: (``console/human_decisions.decision_receipt(...).model_dump(mode="json")``), +#: transcribed from its closed local model. Pinning the producer's real payload +#: is what makes this a shared contract rather than a parallel one: if Flow's +#: half drifts, this fixture stops validating here. +FLOW_WIRE_RECEIPT: dict[str, object] = { + "schema_version": "openadapt.human-decision-receipt/v1", + "task_id": "task_ab12cd34ef56", + "task_revision": 1, + "pause_id": "ab12cd34ef56", + "capability_digest": "sha256:" + "1" * 64, + "request_digest": "sha256:" + "2" * 64, + "decision_digest": "sha256:" + "3" * 64, + "transition_receipt_digest": "sha256:" + "4" * 64, + "action": "verify_and_resume", + "state": "completed", + "reason_code": "verified_and_resumed", + "report_success": True, + "decided_at": "2026-07-26T12:03:04.123456+00:00", +} + + +def test_the_producers_real_wire_payload_validates_unchanged() -> None: + receipt = HumanDecisionReceiptV1.model_validate(FLOW_WIRE_RECEIPT) + + assert receipt.action is HumanDecisionAction.VERIFY_AND_RESUME + assert receipt.state is HumanDecisionReceiptState.COMPLETED + assert receipt.reason_code is HumanDecisionReceiptReason.VERIFIED_AND_RESUMED + assert receipt.succeeded is True + # A microsecond RFC 3339 instant with a numeric offset is what the runtime + # emits; the pattern must accept it without reformatting the signed bytes. + assert receipt.decided_at == FLOW_WIRE_RECEIPT["decided_at"] + + # The producer supplies every required field; the only fields it omits are + # the two optional signing fields, which the local loopback lane does not + # use. Nothing else may be missing, and nothing extra may be added. + supplied = set(FLOW_WIRE_RECEIPT) + declared = set(HumanDecisionReceiptV1.model_fields) + assert supplied <= declared + assert declared - supplied == {"signature_algorithm", "signature"} + required = { + name + for name, field in HumanDecisionReceiptV1.model_fields.items() + if field.is_required() + } + assert required <= supplied