Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions openadapt_types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down
288 changes: 263 additions & 25 deletions openadapt_types/human_decision.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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}$"
Expand Down Expand Up @@ -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)


Expand All @@ -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()


Expand All @@ -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})
Loading
Loading