Skip to content

feat(approval): delegated approvers, read-back field checks, triage and eligibility - #534

Closed
abrichr wants to merge 6 commits into
mainfrom
feat/approval-modes-delegated
Closed

abrichr wants to merge 6 commits into
mainfrom
feat/approval-modes-delegated

Conversation

@abrichr

@abrichr abrichr commented Oct 17, 2026

Copy link
Copy Markdown
Member

For Richard's review. Don't merge: openadapt-flow is frozen for the monorepo cutover. The branch is based on the freeze SHA eced892.

What's in it

Decision triage (from Chief Architect's feat/decision-triage). Commits 4ab7eb29, 1f4e236b and 6fc103fe are cherry-picked as bf02e90, 501a8c2 and 1243141, with authorship kept. The original branch wasn't changed.

Delegated approvers (openadapt_flow/approval_mode/approver.py, item_approval.py, gate.py, CLI)

  • Each run has an approver policy (--approver-policy FILE, openadapt.approver-policy/v1). It's written once to item-approval/approver-policy.json and every reader applies it. With no policy, or an unreadable one, only a person may answer, so nothing changes without opt-in.
  • An agent or system can answer by three routes, and only where the policy allows its kind for the step class:
    • local_cli: item-approval approve|decline --decided-by automation --delegate-kind agent|system --delegate-id ID
    • mcp: the same answer from an MCP tool (openadapt-agent feat: durable tiered runtime — checkpoint + pause/approve/resume from last verified state #80)
    • webhook: run --approver-webhook URL --approver-webhook-id ID, secret from OPENADAPT_APPROVER_WEBHOOK_SECRET. Flow sends PHI-free facts with HMAC-SHA256 in both directions, and the reply must echo the request digest. Any failure leaves the save to a person.
  • Clinical writes:
    • Under the clinical-write and regulated policies, every save is clinical_write.
    • A clinical_write step takes a non-person approver only with a pinned opt-in digest.
  • The report's ItemApprovalRef records decided_by: automation, the route and the approver. Person answers keep their old bytes.
  • Only a person's decision becomes a triage label. Triage never approves.

Read-back field checks (ir.py, runtime/replayer.py)

Tier rule (RFC-0002 names, decided rule)

  • independent_readback is a read of the system of record that doesn't go through the UI: REST, SQL, FHIR, GraphQL or a file export (Flow tier 1).
  • Tiers 2 to 4, including a UI read in a separate session, are screen_readback. They are never clean.
  • Triage has a hard reason, readback_not_independent.

Triage items (a) to (d)

  • (a) learned_fallback stays per_item. Whoever picks learned asked for a measured miss rate, and an uncalibrated group has none; rule_v0 is an explicit choice.
  • (b) Field checks are persisted (above).
  • (c) Recent declines flag only when there are more than 1, or more than 5% of recent items. So one decline among 20 or more items is tolerated. Setting both to 0 restores the old rule. Tested.
  • (d) decision/eligibility.py adds TriageEligibilityProvider. It needs a pinned, eligible calibration artifact plus audit labels, and computes an exact Clopper-Pearson upper bound (bisection, no scipy) that must be under the target. It raises EligibilityUnavailable with a code when evidence is missing. openadapt-flow triage eligibility exits 0 eligible, 3 not yet, 2 no evidence.

Types mirror. Flow's core can't depend on openadapt-types (an optional extra, pinned below 0.22), so approver.py mirrors openadapt_types.approval (types #49). A conformance test checks the two match when both are installed. It passed against the types branch.

Docs

Not in this PR

  • batch stays a post-save review in triage, not a runtime gate.
  • Cloud refuses a run summary whose approval wasn't decided by a human (approvalModeContract.ts, "only a person answers an approval"). A delegated run would be refused there, which fails safe. Showing who approved needs a Cloud change.
  • Eligibility isn't wired into the problems_only requirements yet.

Tests

  • New tests/test_approval_delegated.py (21) covers:
    • the default policy refuses an agent, and clinical writes need the opt-in;
    • an agent can't pose as a person;
    • webhook signing, pending replies and bad replies;
    • delegate polling and failure;
    • person answers keep their bytes;
    • conformance with the types mirror, and the tier mapping.
  • tests/test_decision_triage.py (67) adds the declines threshold, the tier rule, report field checks, Clopper-Pearson and eligibility.
  • Approval-mode tests: 414 passed, 1 skipped, 3 xfailed. ruff check, ruff format and mypy (the whole package, plus strict on the touched safety modules) are clean.
  • Full suite on a heavily loaded Mac: the only errors were in test_qualification_gate_campaign.py, at the 300 s per-test timeout. That file passes on its own (38 passed). A clean rerun is in progress.

… boundary

Field values are compared locally and reduced to match booleans and counts. A DecisionRequest refuses anything outside the fixed feature vocabulary.

Signed-off-by: Richard Abrich <richard.abrich@mldsai.com>
…tch approval

Only problems_only is triaged. Stopped saves are never scored or batched, a batch approval needs a person and writes labels only, and an uncalibrated group sends every save to a person. The learned score uses a numpy logistic regression, Platt or isotonic calibration, a binomial threshold bound and a digest-pinned artifact.

Signed-off-by: Richard Abrich <richard.abrich@mldsai.com>
Signed-off-by: Richard Abrich <richard.abrich@mldsai.com>
…bility

Who may answer a save is now a per-run approver policy (person-only by
default, so nothing changes without one). An agent, rule or system can
answer through the local CLI, MCP or a signed webhook only where the
policy allows its kind for the step class; a clinical write needs a pinned
opt-in. The report records decided_by, the route and the approver
identity. Only a person's decision becomes a triage label.

The verifier keeps each field it compared on the local report
(StepResult.effect_field_checks), so triage from --run-dir can call a save
clean, and Desktop can show the diff. Only an independent read (not
through the UI) is clean. Recent declines tolerate one slip among 20 or
more items. A new eligibility check gives AutonomyEligibilityV1 from a
pinned calibration artifact and an exact Clopper-Pearson bound on the
audit miss rate.

Mirrors openadapt_types.approval (RFC-0002 names) with a conformance
test. Docs: APPROVAL_AND_VERIFICATION.md (incl. the planned Desktop
check-admit path), and fixes the stale "Not built yet" in
APPROVAL_MODES.md and the button labels in EXCEPTION_INBOX.md.

Signed-off-by: Richard Abrich <richard.abrich@mldsai.com>
Signed-off-by: Richard Abrich <richard.abrich@mldsai.com>
Signed-off-by: Richard Abrich <richard.abrich@mldsai.com>
@abrichr

abrichr commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Carried over into the monorepo as OpenAdaptAI/OpenAdapt#1190 (merged as 921ba228e1a9ca1403634eacf9f5ae99d128fadf). openadapt-flow now lives in OpenAdapt/packages/flow.

@abrichr abrichr closed this Oct 17, 2026
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