Skip to content

feat(attended): record what kind of decider answered, not only which identity - #301

Open
abrichr wants to merge 2 commits into
mainfrom
feat/attended-decider-provenance
Open

feat(attended): record what kind of decider answered, not only which identity#301
abrichr wants to merge 2 commits into
mainfrom
feat/attended-decider-provenance

Conversation

@abrichr

@abrichr abrichr commented Jul 28, 2026

Copy link
Copy Markdown
Member

Adds decided_by — a closed human / automation / unknown enum — to Flow's append-only attended decision journal.

The defect

An agreement rate drawn from this journal is a claim about what people concluded when they read the evidence. The journal could not support that claim, and nothing said so.

AttendedDecision.operator records which identity a decision is attributed to. It has never recorded what kind of decider produced it. Those are different questions, and only one of them was answerable.

The MCP bridge in openadapt-agent exposes continue_attention and skip_attention as model-callable tools, and derives its operator from the same _local_operator_identity() a person's own console uses. So a decision a model submitted and one a person submitted from the same machine carried an identical operator string. There is no query that separates them, and there never was — including retroactively, over every decision already recorded.

This is the same shape as the gap reject (#295) was added to close, one level down: a number that cannot be qualified, presented as if it could.

Why not on the receipt, and why not in the request

Not HumanDecisionReceiptV1. That contract carries no operator identity by design — it is the PHI-free artifact that crosses to a phone or a relay. Provenance belongs to the local append-only audit record, which already carries a principal on purpose. The receipt is untouched.

Not AttendedActionRequest. That is what a relay or an MCP client puts on the wire, and request_digest commits to it. Putting provenance there would make the party whose nature is in question the party declaring it. It sits beside operator instead — asserted by the trusted caller, in the same way, for the same reason. Pinned by test_a_submitter_cannot_describe_its_own_nature.

No free text anywhere. A closed enum from the start, so a new member is additive.

The default is the safety property

unknown, not human.

A caller that does not declare is excluded from a human rate rather than silently counted into it. And every decision written before this field existed reads back as unknown — which is true, because the bridge predates the field. Defaulting to human would have re-created the identical bias one level down, in a place nobody would look for it.

The consequence is that the human paths must say they are human, and they do:

Path Value Why it is sound
Local console / paired phone (console/app.py) human operator identity is server-derived from the local OS session
Authenticated remote relay (console/human_decisions.py) human the control plane admits it only at AAL2, bound to a user account
AttendedActionService.execute relayed unchanged it owns the live executor, not the question of who decided

test_the_console_and_the_phone_route_assert_a_person_decided pins the assertion, because if the console ever stopped making it, genuine operator answers would fall silently out of the sample and the failure would look like nothing at all.

The measurement consequence, stated as the query

test_a_human_answer_and_an_automated_answer_are_told_apart_in_the_journal drives both answers into one journal with the same operator string, asserts that operator cannot separate them, and then does what an analyst would actually do:

human_only = [d for d in decisions if d.decided_by == "human"]

and asserts the automated answer is genuinely excluded, not merely labelled. If it could not be, the field would be decoration.

Mutation proof

Each mutation is caught by the test written for it, and by no other.

Mutation Result
Revert all production code, keep tests 12 fail, including all 4 new tests
Parameter default unknownhuman test_an_undeclared_caller_is_excluded_from_a_human_rate_not_counted_into_it, alone
Console stops asserting human test_the_console_and_the_phone_route_assert_a_person_decided + the told-apart test
Move provenance into the signed request test_a_submitter_cannot_describe_its_own_nature, alone

Interface note

AttendedActionService.execute gains a defaulted keyword. Callers are unaffected; implementers must accept it, and there is exactly one real implementer — Flow's own class. openadapt-agent constructs that class rather than its own. The two fakes in this repo's tests are updated, and the console fake now records the value so it can pin what the console asserts.

Local verification

tests/test_attended_actions.py + tests/test_mobile_attended_decision_e2e.py: 88 passed. ruff check openadapt_flow, ruff format --check openadapt_flow tests, mypy (218 files): clean.

Next

openadapt-agent sets decided_by="automation" and gains reject_attention. It depends on openadapt-flow>=1.18.1, so it needs the release that carries this — batched with #294 and #296.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM

…identity

An agreement rate drawn from the attended journal is a claim about what
PEOPLE concluded when they read the evidence. The journal could not
support that claim, and nothing said so.

`AttendedDecision.operator` records WHICH identity a decision is
attributed to. It has never recorded WHAT KIND of decider produced it,
and the two are different questions. The MCP bridge in `openadapt-agent`
exposes `continue_attention` and `skip_attention` as model-callable
tools, and derives its operator from the same
`_local_operator_identity()` a person's own console uses. So a decision a
MODEL submitted and one a PERSON submitted from the same machine carried
an IDENTICAL operator string. Every rate computed over this journal
mixed the two populations with no way to separate them afterwards.

`decided_by` is a closed enum -- human / automation / unknown -- beside
`operator`, asserted by the trusted caller in exactly the same way.

It is deliberately NOT a field on `AttendedActionRequest`. That is what a
relay or an MCP client puts on the wire and `request_digest` commits to
it, so putting provenance there would let the party whose nature is in
question be the party declaring it. Pinned by test.

The default is `unknown`, not `human`, and that is the safety property.
A caller that does not declare is EXCLUDED from a human rate rather than
silently counted into it, and every decision written before this field
existed reads back as `unknown` -- which is true, because the bridge
predates the field. Defaulting to `human` would have re-created the same
bias one level down, where nobody would look for it. The console and the
authenticated AAL2 relay therefore assert `human` explicitly, and a test
pins that they do: if they ever stopped, genuine operator answers would
fall silently out of the sample and the failure would look like nothing.

`AttendedActionService.execute` relays the value unchanged. It owns the
live executor, not the question of who decided, and inventing an answer
there would attribute a decision on behalf of a caller that never made
the claim.

No free text is added anywhere, and the PHI-free `HumanDecisionReceiptV1`
is untouched: it carries no operator identity by design, and provenance
belongs to the local append-only audit record that already carries one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
@abrichr
abrichr enabled auto-merge (squash) July 28, 2026 03:24
@abrichr
abrichr disabled auto-merge July 28, 2026 05:06
@abrichr

abrichr commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Launch-train status (2026-07-28): this PR remains intentionally held behind the exact fresh-frame/OpenEMR campaign gate. Development continues in parallel; this is only a merge-order constraint on Flow main. Before merge, it needs one fresh-main rebase plus the schema-v1 digest/backward-compatibility check called out in the release review. Do not rerun the broad matrix until that bounded correction is ready; we will use one authoritative exact-head run.

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