diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index 7d044db..f258b1c 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -119,11 +119,14 @@ HUMAN_DECISION_RECEIPT_SCHEMA, HUMAN_DECISION_RECEIPT_SUCCESS_STATES, HUMAN_DECISION_TASK_SCHEMA, + HUMAN_DECISION_TASK_V2_SCHEMA, HumanDecisionAction, HumanDecisionDeliveryState, + HumanDecisionEntityFallback, HumanDecisionEvidenceSummaryV1, HumanDecisionQuestionTemplate, HumanDecisionQuestionV1, + HumanDecisionQualifiedEntityV1, HumanDecisionReceiptReason, HumanDecisionReceiptState, HumanDecisionReceiptV1, @@ -133,8 +136,10 @@ HumanDecisionSubstrate, HumanDecisionTaskKind, HumanDecisionTaskV1, + HumanDecisionTaskV2, sign_human_decision_receipt_hmac, sign_human_decision_task_hmac, + sign_human_decision_task_v2_hmac, ) from openadapt_types.parsing import ( PARSE_ERROR_KEY, @@ -270,11 +275,14 @@ "HUMAN_DECISION_RECEIPT_SCHEMA", "HUMAN_DECISION_RECEIPT_SUCCESS_STATES", "HUMAN_DECISION_TASK_SCHEMA", + "HUMAN_DECISION_TASK_V2_SCHEMA", "HumanDecisionAction", "HumanDecisionDeliveryState", + "HumanDecisionEntityFallback", "HumanDecisionEvidenceSummaryV1", "HumanDecisionQuestionTemplate", "HumanDecisionQuestionV1", + "HumanDecisionQualifiedEntityV1", "HumanDecisionReceiptReason", "HumanDecisionReceiptState", "HumanDecisionReceiptV1", @@ -284,8 +292,10 @@ "HumanDecisionSubstrate", "HumanDecisionTaskKind", "HumanDecisionTaskV1", + "HumanDecisionTaskV2", "sign_human_decision_receipt_hmac", "sign_human_decision_task_hmac", + "sign_human_decision_task_v2_hmac", # parsing "PARSE_ERROR_KEY", "from_benchmark_action", diff --git a/openadapt_types/human_decision.py b/openadapt_types/human_decision.py index b364ff0..1b60d8d 100644 --- a/openadapt_types/human_decision.py +++ b/openadapt_types/human_decision.py @@ -2,10 +2,12 @@ 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:`HumanDecisionTaskV1` and :class:`HumanDecisionTaskV2` — portable + projections of an existing runtime pause. V2 additionally binds a safe + qualification-approved entity label to its exact qualified step. Neither is + 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. @@ -15,8 +17,8 @@ Cloud-safe by construction -------------------------- -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 +There are two versioned task models and one receipt model, and all are +Cloud-safe; 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 @@ -53,12 +55,14 @@ ``ensure_ascii=True``). The canonical form is therefore pure ASCII, and a consumer never has to agree on a Unicode normalization form. 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 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 +6. Prepend the version-matched domain separator (for example, + ``b"openadapt.human-decision-task/v1\\x00"`` or + ``b"openadapt.human-decision-task/v2\\x00"``) before computing the HMAC, + so a signature over this contract can never be replayed as a signature over + a different OpenAdapt payload or task version. 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`, :attr:`HumanDecisionTaskV2.digest`, and :attr:`HumanDecisionReceiptV1.digest` are taken over the canonical bytes *without* the domain separator. @@ -96,8 +100,10 @@ ) HUMAN_DECISION_TASK_SCHEMA = "openadapt.human-decision-task/v1" +HUMAN_DECISION_TASK_V2_SCHEMA = "openadapt.human-decision-task/v2" HUMAN_DECISION_RECEIPT_SCHEMA = "openadapt.human-decision-receipt/v1" _SIGNING_DOMAIN = b"openadapt.human-decision-task/v1\x00" +_V2_SIGNING_DOMAIN = b"openadapt.human-decision-task/v2\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}$" @@ -109,6 +115,17 @@ _TIMESTAMP_PATTERN = ( r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$" ) +# Python's datetime parser preserves at most microseconds. V2 does not accept +# precision it cannot compare faithfully. V1 retains its established wire +# contract and pattern unchanged. +_V2_TIMESTAMP_PATTERN = ( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}:\d{2})$" +) +# A qualified label is presentation metadata selected when a workflow is +# qualified. It is not an observation, an identifier, or a runtime-derived +# value. Keep the alphabet deliberately small so the shared contract cannot +# become a general-purpose text channel. +_QUALIFIED_ENTITY_LABEL_PATTERN = r"^[a-z][a-z0-9]*(?:[ _-][a-z0-9]+){0,3}$" class _StrictContract(BaseModel): @@ -201,6 +218,29 @@ class HumanDecisionRequiredAuthn(str, Enum): WEBAUTHN = "webauthn" +class HumanDecisionEntityFallback(str, Enum): + """Domain-neutral text used when a consumer cannot render the label.""" + + RECORD = "record" + ITEM = "item" + + +class HumanDecisionQualifiedEntityV1(_StrictContract): + """A label approved by the bound qualification contract. + + The producer must read this value from the exact qualified contract. This + type intentionally has no observation, screenshot, OCR, parameter, or + model-input field, so those sources cannot cross the decision boundary. + """ + + label: StrictStr = Field( + min_length=1, + max_length=63, + pattern=_QUALIFIED_ENTITY_LABEL_PATTERN, + ) + fallback: HumanDecisionEntityFallback + + class HumanDecisionSafeSlotsV1(_StrictContract): """Bounded numeric context safe to relay outside the evidence boundary.""" @@ -355,6 +395,40 @@ def verify_hmac(self, key: bytes) -> bool: return hmac.compare_digest(expected, self.signature) +class HumanDecisionTaskV2(HumanDecisionTaskV1): + """V2 task with qualification-bound, safe entity presentation metadata. + + V2 is a separate signed wire format. It does not alter V1 canonical + bytes, validation, schema ID, or signing domain. + """ + + schema_version: Literal["openadapt.human-decision-task/v2"] = ( + HUMAN_DECISION_TASK_V2_SCHEMA + ) + # Cloud's JavaScript reader accepts signed revisions only through this + # maximum. V2 must not sign a value that an existing consumer cannot + # round-trip; V1 remains unchanged for byte compatibility. + task_revision: StrictInt = Field(default=1, ge=1, le=2_147_483_647) + created_at: StrictStr = Field( + min_length=20, max_length=40, pattern=_V2_TIMESTAMP_PATTERN + ) + expires_at: StrictStr = Field( + min_length=20, max_length=40, pattern=_V2_TIMESTAMP_PATTERN + ) + qualification_project_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + qualification_revision_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + qualification_contract_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + qualification_step_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + entity: HumanDecisionQualifiedEntityV1 + + def verify_hmac(self, key: bytes) -> bool: + """Verify V2 only under its distinct domain separator.""" + + _validate_hmac_key(key) + expected = _hmac_signature(key, self.unsigned_payload(), _V2_SIGNING_DOMAIN) + return hmac.compare_digest(expected, self.signature) + + def _validate_hmac_key(key: bytes) -> None: if not isinstance(key, bytes) or len(key) < 32: raise ValueError("human decision HMAC key must contain at least 32 bytes") @@ -391,6 +465,24 @@ def sign_human_decision_task_hmac( return validated.model_copy(update={"signature": signature}) +def sign_human_decision_task_v2_hmac( + *, key: bytes, fields: Mapping[str, Any] +) -> HumanDecisionTaskV2: + """Validate and sign a qualification-bound task using the V2 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_TASK_V2_SCHEMA) + unsigned.setdefault("signature_algorithm", "hmac-sha256") + validated = HumanDecisionTaskV2.model_validate( + {**unsigned, "signature": "hmac-sha256:" + "0" * 64} + ) + signature = _hmac_signature(key, validated.unsigned_payload(), _V2_SIGNING_DOMAIN) + return validated.model_copy(update={"signature": signature}) + + class HumanDecisionReceiptState(str, Enum): """Terminal outcome of one attended decision, as the runtime reports it. diff --git a/openadapt_types/schemas/human-decision-task-v2.json b/openadapt_types/schemas/human-decision-task-v2.json new file mode 100644 index 0000000..af0db56 --- /dev/null +++ b/openadapt_types/schemas/human-decision-task-v2.json @@ -0,0 +1,467 @@ +{ + "$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.\n\n``reject`` and ``escalate`` are deliberately separate members rather than\ntwo labels on one action, because they do opposite things to the run.\n``escalate`` *parks* it: the durable pause stays intact and a qualified\ncolleague can still continue it. ``reject`` *terminates* it: the operator\nlooked at the live application and is asserting that this run must not\nproceed at all. Collapsing the two would leave the recorded answer\ndistribution unable to distinguish \"I don't know\" from \"stop\", which is the\nonly reason a disagreement action is worth its cost.\n\n``reject`` is scoped to one run. It is not a claim that the saved workflow\nis wrong; that assertion changes future runs and belongs to ``teach``,\nwhich carries the demonstration and requalification gate such authority\nrequires.\n\n``reconcile`` asks the runner to re-establish the business effect after an\nuncertain delivery. It is intentionally distinct from\n``verify_and_resume``: reconciliation may prove that the original action\nalready succeeded, and therefore must never imply permission to dispatch\nthat action again.", + "enum": [ + "verify_and_resume", + "skip", + "reject", + "teach", + "escalate", + "reconcile" + ], + "title": "HumanDecisionAction", + "type": "string" + }, + "HumanDecisionDeliveryState": { + "enum": [ + "not_delivered", + "delivered", + "unknown" + ], + "title": "HumanDecisionDeliveryState", + "type": "string" + }, + "HumanDecisionEntityFallback": { + "description": "Domain-neutral text used when a consumer cannot render the label.", + "enum": [ + "record", + "item" + ], + "title": "HumanDecisionEntityFallback", + "type": "string" + }, + "HumanDecisionEvidenceSummaryV1": { + "additionalProperties": false, + "properties": { + "effect_confirmed_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Effect Confirmed Count" + }, + "effect_required_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Effect Required Count" + }, + "frame_available_locally": { + "default": false, + "title": "Frame Available Locally", + "type": "boolean" + }, + "identity_confirmed_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identity Confirmed Count" + }, + "identity_required_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identity Required Count" + }, + "minimum_effect_tier": { + "anyOf": [ + { + "maximum": 4, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Minimum Effect Tier" + }, + "observed_effect_tier": { + "anyOf": [ + { + "maximum": 4, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Observed Effect Tier" + }, + "sensitive_evidence_local_only": { + "const": true, + "default": true, + "title": "Sensitive Evidence Local Only", + "type": "boolean" + } + }, + "title": "HumanDecisionEvidenceSummaryV1", + "type": "object" + }, + "HumanDecisionQualifiedEntityV1": { + "additionalProperties": false, + "description": "A label approved by the bound qualification contract.\n\nThe producer must read this value from the exact qualified contract. This\ntype intentionally has no observation, screenshot, OCR, parameter, or\nmodel-input field, so those sources cannot cross the decision boundary.", + "properties": { + "fallback": { + "$ref": "#/$defs/HumanDecisionEntityFallback" + }, + "label": { + "maxLength": 63, + "minLength": 1, + "pattern": "^[a-z][a-z0-9]*(?:[ _-][a-z0-9]+){0,3}$", + "title": "Label", + "type": "string" + } + }, + "required": [ + "label", + "fallback" + ], + "title": "HumanDecisionQualifiedEntityV1", + "type": "object" + }, + "HumanDecisionQuestionTemplate": { + "enum": [ + "confirm_identity", + "confirm_persisted_effect", + "resolve_ambiguity", + "complete_human_step", + "review_uncertain_delivery", + "review_halt" + ], + "title": "HumanDecisionQuestionTemplate", + "type": "string" + }, + "HumanDecisionQuestionV1": { + "additionalProperties": false, + "properties": { + "safe_slots": { + "$ref": "#/$defs/HumanDecisionSafeSlotsV1" + }, + "template": { + "$ref": "#/$defs/HumanDecisionQuestionTemplate" + } + }, + "required": [ + "template" + ], + "title": "HumanDecisionQuestionV1", + "type": "object" + }, + "HumanDecisionRequiredAuthn": { + "enum": [ + "local_session", + "aal2", + "webauthn" + ], + "title": "HumanDecisionRequiredAuthn", + "type": "string" + }, + "HumanDecisionRiskClass": { + "enum": [ + "read_only", + "state_changing", + "consequential", + "irreversible", + "unknown" + ], + "title": "HumanDecisionRiskClass", + "type": "string" + }, + "HumanDecisionSafeSlotsV1": { + "additionalProperties": false, + "description": "Bounded numeric context safe to relay outside the evidence boundary.", + "properties": { + "candidate_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Candidate Count" + }, + "confirmed_signal_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirmed Signal Count" + }, + "required_signal_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Required Signal Count" + } + }, + "title": "HumanDecisionSafeSlotsV1", + "type": "object" + }, + "HumanDecisionSubstrate": { + "enum": [ + "browser", + "windows", + "macos", + "linux", + "rdp", + "citrix", + "mixed", + "unknown" + ], + "title": "HumanDecisionSubstrate", + "type": "string" + }, + "HumanDecisionTaskKind": { + "enum": [ + "identity", + "effect", + "ambiguity", + "human_step", + "delivery_uncertain", + "halt", + "operator_review" + ], + "title": "HumanDecisionTaskKind", + "type": "string" + } + }, + "additionalProperties": false, + "description": "V2 task with qualification-bound, safe entity presentation metadata.\n\nV2 is a separate signed wire format. It does not alter V1 canonical\nbytes, validation, schema ID, or signing domain.", + "properties": { + "allowed_actions": { + "items": { + "$ref": "#/$defs/HumanDecisionAction" + }, + "maxItems": 6, + "minItems": 1, + "title": "Allowed Actions", + "type": "array" + }, + "bundle_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Bundle Digest", + "type": "string" + }, + "capability_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Capability 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" + }, + "delivery_state": { + "$ref": "#/$defs/HumanDecisionDeliveryState" + }, + "entity": { + "$ref": "#/$defs/HumanDecisionQualifiedEntityV1" + }, + "evidence": { + "$ref": "#/$defs/HumanDecisionEvidenceSummaryV1" + }, + "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" + }, + "nonce": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Nonce", + "type": "string" + }, + "pause_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Pause Id", + "type": "string" + }, + "qualification_contract_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Qualification Contract Digest", + "type": "string" + }, + "qualification_project_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Qualification Project Id", + "type": "string" + }, + "qualification_revision_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Qualification Revision Id", + "type": "string" + }, + "qualification_step_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Qualification Step Id", + "type": "string" + }, + "question": { + "$ref": "#/$defs/HumanDecisionQuestionV1" + }, + "required_authn": { + "$ref": "#/$defs/HumanDecisionRequiredAuthn" + }, + "risk_class": { + "$ref": "#/$defs/HumanDecisionRiskClass", + "default": "unknown" + }, + "run_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Run Id", + "type": "string" + }, + "runner_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Runner Id" + }, + "schema_version": { + "const": "openadapt.human-decision-task/v2", + "default": "openadapt.human-decision-task/v2", + "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" + }, + "substrate": { + "$ref": "#/$defs/HumanDecisionSubstrate", + "default": "unknown" + }, + "task_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Task Id", + "type": "string" + }, + "task_kind": { + "$ref": "#/$defs/HumanDecisionTaskKind" + }, + "task_revision": { + "default": 1, + "maximum": 2147483647, + "minimum": 1, + "title": "Task Revision", + "type": "integer" + }, + "tenant_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tenant Id" + } + }, + "required": [ + "task_id", + "run_id", + "pause_id", + "capability_digest", + "bundle_digest", + "task_kind", + "delivery_state", + "question", + "evidence", + "allowed_actions", + "required_authn", + "created_at", + "expires_at", + "nonce", + "issuer_key_id", + "signature", + "qualification_project_id", + "qualification_revision_id", + "qualification_contract_digest", + "qualification_step_id", + "entity" + ], + "title": "HumanDecisionTaskV2", + "type": "object" +} diff --git a/scripts/export_control_overlay_schemas.py b/scripts/export_control_overlay_schemas.py index a84363a..372a0e3 100644 --- a/scripts/export_control_overlay_schemas.py +++ b/scripts/export_control_overlay_schemas.py @@ -15,6 +15,7 @@ ExecutionRequirementsV1, HumanDecisionReceiptV1, HumanDecisionTaskV1, + HumanDecisionTaskV2, RunnerCapabilityManifestV1, match_runner_capabilities, ) @@ -28,6 +29,7 @@ "control-overlay-frame-v2.json": ControlOverlayFrameV2, "control-overlay-timeline-v2.json": ControlOverlayTimelineV2, "human-decision-task-v1.json": HumanDecisionTaskV1, + "human-decision-task-v2.json": HumanDecisionTaskV2, "human-decision-receipt-v1.json": HumanDecisionReceiptV1, "runner-capability-manifest-v1.json": RunnerCapabilityManifestV1, "execution-requirements-v1.json": ExecutionRequirementsV1, diff --git a/tests/test_human_decision.py b/tests/test_human_decision.py index 980827d..890595a 100644 --- a/tests/test_human_decision.py +++ b/tests/test_human_decision.py @@ -13,9 +13,11 @@ HUMAN_DECISION_RECEIPT_SUCCESS_STATES, HumanDecisionAction, HumanDecisionDeliveryState, + HumanDecisionEntityFallback, HumanDecisionEvidenceSummaryV1, HumanDecisionQuestionTemplate, HumanDecisionQuestionV1, + HumanDecisionQualifiedEntityV1, HumanDecisionReceiptReason, HumanDecisionReceiptState, HumanDecisionReceiptV1, @@ -23,8 +25,10 @@ HumanDecisionSafeSlotsV1, HumanDecisionTaskKind, HumanDecisionTaskV1, + HumanDecisionTaskV2, sign_human_decision_receipt_hmac, sign_human_decision_task_hmac, + sign_human_decision_task_v2_hmac, ) @@ -62,6 +66,20 @@ def _task_fields() -> dict[str, object]: } +def _v2_task_fields() -> dict[str, object]: + return { + **_task_fields(), + "qualification_project_id": "project_12345678", + "qualification_revision_id": "revision_12345678", + "qualification_contract_digest": "sha256:" + "c" * 64, + "qualification_step_id": "step_12345678", + "entity": HumanDecisionQualifiedEntityV1( + label="service record", + fallback=HumanDecisionEntityFallback.RECORD, + ), + } + + def test_signed_task_detects_any_tamper_without_becoming_authority() -> None: key = b"k" * 32 task = sign_human_decision_task_hmac(key=key, fields=_task_fields()) @@ -99,6 +117,155 @@ def test_packaged_schema_matches_the_strict_language_agnostic_contract() -> None assert json.loads(packaged.read_text()) == schema +def test_v2_binds_the_qualified_label_to_one_exact_qualified_step() -> None: + key = b"k" * 32 + task = sign_human_decision_task_v2_hmac(key=key, fields=_v2_task_fields()) + + assert task.verify_hmac(key) + assert task.entity.label == "service record" + assert task.entity.fallback is HumanDecisionEntityFallback.RECORD + + # These bindings and the label itself are all signed. A receiver must not + # treat a label approved for one contract revision or step as approved for + # another. + for field, value in ( + ("qualification_project_id", "project_87654321"), + ("qualification_revision_id", "revision_87654321"), + ("qualification_contract_digest", "sha256:" + "d" * 64), + ("qualification_step_id", "step_87654321"), + ( + "entity", + HumanDecisionQualifiedEntityV1( + label="service item", fallback=HumanDecisionEntityFallback.ITEM + ), + ), + ): + assert not task.model_copy(update={field: value}).verify_hmac(key) + + +def test_v2_uses_a_distinct_schema_and_signature_domain_from_v1() -> None: + key = b"k" * 32 + v1 = sign_human_decision_task_hmac(key=key, fields=_task_fields()) + v2 = sign_human_decision_task_v2_hmac(key=key, fields=_v2_task_fields()) + + assert v1.schema_version == "openadapt.human-decision-task/v1" + assert v2.schema_version == "openadapt.human-decision-task/v2" + v1_domain_signature = "hmac-sha256:" + hmac.new( + key, + b"openadapt.human-decision-task/v1\x00" + v2.canonical_unsigned_bytes(), + hashlib.sha256, + ).hexdigest() + assert v2.signature != v1_domain_signature + + +# V2 has its own frozen cross-language vector. It pins the new schema, +# qualification bindings, label, fallback, canonicalization, and V2 domain. +V2_CANONICAL_VECTOR_DIGEST = ( + "sha256:a811546096b8d6b259a05a0a49c3edc391e4030c4aa474224d723b158ad26fae" +) +V2_CANONICAL_VECTOR_SIGNATURE = ( + "hmac-sha256:0bc2b10d8b4b2b70eee93dae3903aea4d238552e2c8a95c3e2aa74da9812935e" +) + + +def test_v2_canonical_vector_is_stable_for_other_languages() -> None: + task = sign_human_decision_task_v2_hmac(key=b"k" * 32, fields=_v2_task_fields()) + + assert task.digest == V2_CANONICAL_VECTOR_DIGEST + assert task.signature == V2_CANONICAL_VECTOR_SIGNATURE + + +def test_v2_task_revision_stays_inside_the_shared_javascript_range() -> None: + maximum = 2_147_483_647 + accepted = sign_human_decision_task_v2_hmac( + key=b"k" * 32, + fields={**_v2_task_fields(), "task_revision": maximum}, + ) + assert accepted.task_revision == maximum + + with pytest.raises(ValidationError): + sign_human_decision_task_v2_hmac( + key=b"k" * 32, + fields={**_v2_task_fields(), "task_revision": maximum + 1}, + ) + + +@pytest.mark.parametrize( + "timestamp", + ["2026-07-26T12:00:00.1234567Z", "2026-07-26T12:00:00.123456789Z"], +) +def test_v2_refuses_timestamp_precision_python_cannot_compare(timestamp: str) -> None: + with pytest.raises(ValidationError): + sign_human_decision_task_v2_hmac( + key=b"k" * 32, + fields={**_v2_task_fields(), "created_at": timestamp}, + ) + + +def test_v2_compares_microsecond_timestamps_without_truncation() -> None: + fields = { + **_v2_task_fields(), + "created_at": "2026-07-26T12:00:00.123456Z", + "expires_at": "2026-07-26T12:00:00.123457Z", + } + accepted = sign_human_decision_task_v2_hmac(key=b"k" * 32, fields=fields) + assert accepted.created_at == fields["created_at"] + + with pytest.raises(ValidationError, match="expires_at must be after created_at"): + sign_human_decision_task_v2_hmac( + key=b"k" * 32, + fields={ + **fields, + "created_at": "2026-07-26T12:00:00.123457Z", + "expires_at": "2026-07-26T12:00:00.123456Z", + }, + ) + + +def test_v2_round_trips_and_refuses_runtime_evidence_or_unknown_fields() -> None: + task = sign_human_decision_task_v2_hmac(key=b"k" * 32, fields=_v2_task_fields()) + payload = task.model_dump(mode="json") + assert HumanDecisionTaskV2.model_validate_json(task.model_dump_json()) == task + + for field, value in { + "screenshot": "data:image/png;base64,secret", + "ocr_text": "Jane Doe", + "parameter_value": "0093211", + "model_inference": "patient", + }.items(): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + HumanDecisionTaskV2.model_validate({**payload, field: value}) + + with pytest.raises(ValidationError): + HumanDecisionTaskV2.model_validate( + { + **payload, + "entity": {**payload["entity"], "source": "ocr"}, + } + ) + + +@pytest.mark.parametrize( + "label", + ["Jane Doe", "patient: 0093211", "record\nname", "", "résumé"], +) +def test_v2_entity_label_is_not_a_free_text_or_identifier_channel(label: str) -> None: + fields = _v2_task_fields() + fields["entity"] = {"label": label, "fallback": "record"} + with pytest.raises(ValidationError): + sign_human_decision_task_v2_hmac(key=b"k" * 32, fields=fields) + + +def test_packaged_v2_schema_matches_the_strict_language_agnostic_contract() -> None: + schema = HumanDecisionTaskV2.model_json_schema() + assert schema["additionalProperties"] is False + assert unconstrained_string_paths(schema) == [] + packaged = files("openadapt_types.schemas").joinpath( + "human-decision-task-v2.json" + ) + assert json.loads(packaged.read_text()) == schema + + #: Every category of protected content the Cloud-safe task must be unable to #: carry, with a representative field name and a value that would be a real #: disclosure. Enumerating the categories, rather than testing one field, is