Skip to content

fix: close the attended terminal-outcome contract gap with HumanDecisionReceiptV1 - #13

Merged
abrichr merged 1 commit into
mainfrom
feat/human-decision-receipt-v1
Jul 27, 2026
Merged

fix: close the attended terminal-outcome contract gap with HumanDecisionReceiptV1#13
abrichr merged 1 commit into
mainfrom
feat/human-decision-receipt-v1

Conversation

@abrichr

@abrichr abrichr commented Jul 27, 2026

Copy link
Copy Markdown
Member

Closes the last shared-contract gap blocking Cloud from validating the terminal
outcome openadapt-flow now produces. The signed task contract shipped in
0.6.0/0.6.1; the receipt that closes the round trip did not, so Flow
PR #290 had to define
its half locally and mark the seam. This lands the shared type.

The shape is derived from Flow's real terminal decision states, not designed
fresh. Both of Flow's proposed departures from the design memo were verified
against its branch (feat/mobile-attended-decision-e2e) rather than accepted:

  • demonstration_requested / escalated are genuinely terminal. The engine
    writes AttendedDecision(status="needs_demonstration") and
    status="escalated" at runtime/durable/attended.py:1123 and :1144, both
    returning immediately with no runner continuation pending. Folding them
    into accepted_pending_runner would tell an operator to wait for something
    that never arrives. Confirmed — kept as distinct states.
  • Separating state from reason_code is necessary: one state has more than
    one cause (completed is reached both by verified_and_resumed and by
    skipped_and_resumed). Confirmed — kept, with one change (below).

The shape

schema_version               const "openadapt.human-decision-receipt/v1"
task_id, pause_id            opaque-id pattern
task_revision                int >= 1
capability_digest            sha256: pattern
request_digest               sha256: pattern
decision_digest              sha256: pattern
transition_receipt_digest    sha256: pattern | null
action                       HumanDecisionAction (the task's portable enum)
state                        HumanDecisionReceiptState (8 values)
reason_code                  HumanDecisionReceiptReason (9 values)
report_success               bool | null
decided_at                   RFC 3339 pattern
signature_algorithm          const "hmac-sha256"
signature                    hmac-sha256 pattern | null

How each protected category is made structurally unrepresentable

The engine's AttendedDecision is an append-only audit record that carries a
free-text message (attended.py:245) and an operator principal
(attended.py:233) on purpose. Neither may reach a phone or Cloud. The
receipt is therefore a rebuilt value, not a filtered copy — strip-on-send
stays correct only until someone adds a field; a closed target type has no such
failure mode. This mirrors the task contract exactly (one model, and it is the
Cloud-safe one) rather than introducing a second pattern.

Category Why it cannot travel Test
Operator prose / message There is no free-text field. The explanation is reason_code, a closed 9-value enum test_receipt_reason_code_is_closed_and_never_free_text, test_receipt_has_no_free_text_field_at_all
Operator identity No operator field exists; asserted by name test_receipt_has_no_free_text_field_at_all
Screenshots, OCR, expected/observed values, identifiers, unknown fields extra="forbid"; each category enumerated with representative field names and real-disclosure values test_receipt_rejects_each_forbidden_category (6 categories, same table the task uses)
Free text inside a declared string Every string in the exported schema is closed by pattern, const, or enum. The 0.6.1 structural guard now runs over both contracts via a shared unconstrained_string_paths() walker — extended, not weakened test_receipt_has_no_free_text_field_at_all
Text smuggled through a timestamp decided_at carries the same RFC 3339 pattern 0.6.1 put on created_at/expires_at, so it reaches the exported schema a non-Python consumer validates against — not only a length bound test_receipt_timestamps_cannot_smuggle_text_or_drop_their_offset
Evidence payloads Only one-way digests. A consumer binds the receipt to the exact capability, request, decision record, and transition receipt without receiving any of them shape review

Uncertainty stays first-class

Acceptance criterion: "loss of network after tap shows pending/uncertain, never
success."
delivery_uncertain is a required terminal state (the field cannot be
omitted), accepted_pending_runner is distinct from it, and succeeded is
false for both. HumanDecisionDeliveryState's three-value vocabulary
(not_delivered / delivered / unknown) is re-asserted alongside.
test_receipt_delivery_uncertainty_is_a_first_class_value

Two additions beyond Flow's proposal

  1. state and reason_code are two fields but not two independent fields.
    Flow's proposal left the pair unconstrained, which would let a producer emit
    state=completed, reason_code=expired. HUMAN_DECISION_RECEIPT_REASONS pins
    the permitted combinations and a model validator enforces them; the map is
    total over both enums. Every pair Flow's _RECEIPT_STATE produces is
    permitted, so Flow needs no change.
    test_receipt_state_and_reason_code_cannot_be_paired_arbitrarily
  2. report_success cannot be true outside a success state. Runnable is not
    certified: an uncertain delivery, halt, or refusal must not be renderable as
    success by a consumer that only reads the boolean. Verified against Flow:
    report_success=resumed.success is always paired with
    status="completed" if resumed.success else "halted", and every refusal path
    sets report_success=False. Flow needs no change.
    test_receipt_cannot_report_success_outside_a_success_state

Signing and the canonicalization vector

Same normative canonicalization as 0.6.1 (sorted ASCII keys, no insignificant
whitespace, ensure_ascii, nulls never omitted, signature excluded), reused
verbatim — the module docstring now covers both contracts. _hmac_signature()
takes the domain separator as a required argument so a third contract cannot
silently reuse the task's.

  • Receipt domain: b"openadapt.human-decision-receipt/v1\x00" — a task
    signature can never be replayed as a receipt signature.
    test_receipt_signature_is_domain_separated_from_the_task_signature
  • Frozen vector (test_receipt_canonical_bytes_and_signature_are_pinned_for_other_languages),
    digest and signature computed independently from the pinned byte literal:
    • digest sha256:1fc191fc298e155c305a744e816bf58c09ac07a68b9096d13f34aa0eba6797c6
    • signature hmac-sha256:385e7802b4c1ec8ff557f4c15e8b1e8c63d87fbffb08b9d61df27c59e9c44c34 (key b"k"*32)
  • No existing signed bytes changed. The 0.6.1 task vector
    (CANONICAL_VECTOR_BYTES / _DIGEST / _SIGNATURE) is untouched and still
    passes; human-decision-task-v1.json has a zero-line diff.

signature is optional because Flow's console returns the receipt over
authenticated loopback (console/server.py binds 127.0.0.1 only) and does not
sign. An unsigned receipt never verifies
(test_unsigned_receipt_never_verifies), so a remote consumer that requires a
signature is not weakened by the default.

Acceptance criteria satisfied

  • loss of network after tap shows pending/uncertain, never success — the
    receipt can state delivery_uncertain and accepted_pending_runner as
    terminal outcomes, and neither reports success.
  • terminal outcome cannot represent protected content — no field exists for it.
  • Cloud-safe schema rejects screenshots / OCR / values / free text / identifiers
    / unknown fields
    — enumerated per category, rejected structurally.
  • VERIFIED impossible without the complete contractreport_success is
    unrepresentable as true outside completed.

Producer parity

test_the_producers_real_wire_payload_validates_unchanged pins Flow's exact
route body and asserts it validates with nothing missing and nothing extra: the
only fields Flow omits are the two optional signing fields. If Flow's half
drifts, that fixture stops validating here.

Version intent: 0.6.2 (patch)

openadapt-flow pins openadapt-types>=0.6.0,<0.7.0. Adding a model is
additive, so this must be a patch: 0.7.0 would strand every consumer and force a
coordinated bump. This repo's semantic_release config maps fix → patch and
feat → minor, so the commit is deliberately typed fix: — the contract gap
is a real defect (a shipped producer whose payload no consumer can validate),
and the type is what selects the version. No consumer pin is touched.

Gates

  • uv run pytest181 passed (149 before; +32), uv 0.11.29 / Python
    3.12, matching .github/workflows/test.yml exactly.
  • ruff 0.15.22 check — 4 findings, byte-identical to origin/main; zero new.
  • ruff 0.15.22 format — the set of unformatted files is byte-identical to
    origin/main; all new code is format-clean.
  • mypy 1.19.0 (pydantic plugin) — one new diagnostic at the
    Literal[...] = CONSTANT default, the identical idiom already used by
    HumanDecisionTaskV1 and both control-overlay contracts. No new error class.
    (Neither ruff nor mypy is a CI gate in this repo; both were run for parity.)
  • uv build — the new human-decision-receipt-v1.json ships in both the wheel
    and the sdist.

Mutation-checked guards

Every new guard was removed, its test confirmed failing, and the guard restored.
All 10 detected:

extra="forbid" · reason_code closed enum · decided_at RFC 3339 pattern ·
state/reason pairing validator · report_success success-state guard · receipt
signing-domain separation · delivery_uncertain as a first-class state ·
portable action vocabulary · unsigned-receipt verification · packaged-schema
drift.

What Cloud must do to consume it

  1. Bump to openadapt-types>=0.6.2,<0.7.0 (inside the existing pin range).
  2. Validate every attended decision response against
    HumanDecisionReceiptV1 / openadapt_types/schemas/human-decision-receipt-v1.json.
    Reject on failure — never coerce, never ignore unknown fields.
  3. Render operator-facing copy from (state, reason_code) only. There is no
    message to display, by design.
  4. Treat succeeded (i.e. state == "completed") as the only success. 202 +
    delivery_uncertain and accepted_pending_runner are not failures and
    not successes; show "may have been sent" and do not auto-retry.
  5. For the remote relay lane, require a non-null signature and verify it with
    verify_hmac() under the receipt domain separator. Keep tenant/runner/
    event-sequence binding in the signed transport envelope — the receipt
    deliberately does not duplicate those identifiers.
  6. Correlate to the run via task_id / pause_id / capability_digest, which
    Cloud already holds from the signed task projection.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM

…ionReceiptV1

The signed human-decision *task* contract shipped in 0.6.0/0.6.1, but the
*receipt* that closes the round trip did not. `openadapt-flow` already returns
a terminal receipt from its console decision route and had to define its own
half locally, so no consumer outside Flow can validate the shape Flow actually
produces. Cloud cannot check the one payload that reports whether an operator's
decision resumed, halted, or may have been delivered.

`HumanDecisionReceiptV1` closes that gap. It is a closed type in which
protected content is structurally unrepresentable rather than stripped on send:
no free-text field, `reason_code` a closed enum, every string closed by a
pattern/const/enum, unknown fields rejected, RFC 3339 timestamps, and
uncertainty a first-class terminal state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
@abrichr
abrichr merged commit 4eb190f into main Jul 27, 2026
1 check passed
@abrichr
abrichr deleted the feat/human-decision-receipt-v1 branch July 27, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant