Skip to content

Flow: person-signed approver grants, no self-approval, one-tap phone approval on the decision portal - #1234

Merged
abrichr merged 8 commits into
mainfrom
flow/signed-approvers-phone-approval
Oct 18, 2026
Merged

abrichr merged 8 commits into
mainfrom
flow/signed-approvers-phone-approval

Conversation

@abrichr

@abrichr abrichr commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Do not merge while the main merge freeze is in place.

What changes

Person-signed approver grants. An agent, rule or system can answer a save only under an approver grant (openadapt-flow/approver-grant/v1) that a person signed with a policy_signers key in the JIT authority's principals.json. It reuses #1207's domain-separated Ed25519 signing. The grant names the delegates, the workflows it covers and its expiry (366 days at most). --approver-policy now takes a signed grant. An unsigned policy that allows a non-person, a tampered or expired grant, or a grant signed by an agent's key gives no delegate approval: the run warns and waits for a person. New commands: approver-grant sign (needs a terminal and a typed yes) and approver-grant verify.

Nobody approves their own work. Each run records who started it (--started-by; the agent runner always passes it). A delegate is refused for a run it started, a run started by whoever it acts for, and a run whose starter is unknown. The grant is re-verified on every answer.

One-tap phone approval, on the decision portal. A save waiting for a person is now a signed openadapt.human-decision-task/v1 (task_kind: save_approval, question approve_save) served by the attended console the decision portal already relays to:

  • GET /api/save-approvals: the waiting saves.
  • GET /api/save-approvals/{id}: the signed PHI-free task plus a local-only presentation block.
  • GET /api/save-approvals/{id}/frame: the exact screenshot, checked against its digest.
  • POST /api/save-approvals/{id}/actions/{verify_and_resume|reject}: the answer. A decline needs one closed reason.

The task's capability_digest is the request digest (run, step, screenshot, form state). The phone echoes the task digest and signature. A changed or foreign task is refused. The answer is written once on the new decision_portal person route, so a replay or second tap has nothing to answer. The run still re-reads the form before the save. Nothing new leaves the runner. Local-only and no-outbound installs work the same way, and the hosted lane doesn't carry save approvals, so Cloud never gets the screenshot. The earlier Cloud relay (--phone-approval, runtime.phone_approval, item-approval pin-cloud-keys / apply-cloud-decision, openadapt.cloud-item-decision/v1) has been removed.

Who decided. item-approval show returns decided_by. The report's approval rows carry the route (desktop_run_it, decision_portal, or the delegate's route) and, for a delegate, its identity and grant hash (policy_ref).

Docs

docs/APPROVAL_AND_VERIFICATION.md and docs/APPROVAL_MODES.md have new sections:

  • A person signs the grant
  • Nobody approves their own work
  • A person approves from their phone

Tests

  • New tests/test_approval_signed_grant.py covers grants, tampering, expiry, an agent's key signing, self-approval, and the run's starter.
  • New tests/test_console_save_approvals.py covers the portal task (PHI-free, signed, stable), the exact screenshot, single-use answers, closed decline reasons, foreign/forged tasks, expiry, a swapped screenshot, and attended-only answering.
  • tests/test_approval_delegated.py now uses signed grants.
  • HumanDecisionTaskV1/V2 schemas regenerated for the two new enum members. The public artifact inventory is refreshed.

Related: OpenAdaptAI/openadapt-desktop#207 (portal relay for these routes), OpenAdaptAI/openadapt-desktop#206 and OpenAdaptAI/openadapt-cloud#419 (who approved, in Desktop and Cloud run views).

…phone approval

- An agent, rule or system approves saves only under an approver grant a
  person signed with a policy_signers key (reusing the JIT authority's
  Ed25519 registry). An unsigned, tampered, expired or agent-signed grant
  means no delegate approval; the run falls back to a person.
- Nobody approves their own work: each run records who started it, and a
  delegate can't answer a run it (or whoever it acts for) started, or a run
  whose starter is unknown.
- One-tap phone approval (opt-in): waiting saves go to Cloud over the
  runner's own connection; a person approves from their phone after an AAL2
  sign-in. The run accepts only a Cloud-signed decision for the exact request
  digest from a pinned key, writes it once, and re-checks the form before the
  save. Desktop keeps working and wins if it answers first.
- item-approval show and the report name who decided: a person (Desktop or
  phone) or a named delegate with its grant hash.
…, not a Cloud relay

Drop the separate Cloud phone relay (phone_relay, cloud_decision,
--phone-approval, runtime.phone_approval, item-approval pin-cloud-keys and
apply-cloud-decision). A waiting save is now a HumanDecisionTaskV1 with
task_kind save_approval / question approve_save, served by the attended
console the decision portal already relays to:

  GET  /api/save-approvals
  GET  /api/save-approvals/{request_id}          signed task + local presentation
  GET  /api/save-approvals/{request_id}/frame    exact screenshot, digest-checked
  POST /api/save-approvals/{request_id}/actions/{verify_and_resume|reject}

The task's capability_digest is the request digest (run, step, screenshot,
form state). The phone echoes the task digest and signature; the answer is
written once on the new decision_portal person route, and the run still
re-reads the form before saving. The hosted lane does not carry save
approvals: Cloud never receives the screenshot.
@abrichr abrichr changed the title feat(flow): person-signed approver grants, no self-approval, one-tap phone approval Flow: person-signed approver grants, no self-approval, one-tap phone approval on the decision portal Oct 18, 2026
abrichr added a commit that referenced this pull request Oct 18, 2026
An accepted admission now admits the workflow to the problems_only approval
mode: its runs stop only for problems (a failed check, a changed screen, a
step outside what was qualified) instead of before every save.
openadapt-flow run reads this from the bundle's admission-decision.json and
prints who admitted it. A flag or runtime.approval_mode still wins.

The admitter is a person, or a delegated agent acting under an approver
grant a registered person signed (#1234's openadapt-flow/approver-grant/v1),
replacing the unsigned delegate decision. The grant must verify, name the
agent, cover the workflow and let an agent approve consequential saves. The
decision records the grant's digest, its signer and the signed grant itself.

The mode applies only to the exact admitted bundle, and an agent's admission
only while its grant still verifies. problems_only keeps its own gates, so
every save still waits for a person until the switch is earned.

MCP: openadapt-agent serve --admissions DIR --delegate-approvals ID
--approver-grant FILE.
…-phone-approval

# Conflicts:
#	packages/flow/openadapt_flow/console/app.py
abrichr and others added 2 commits October 18, 2026 01:58
A run that gets no answer in time stops before the save and leaves a
durable item_approval pause. The command line carried a later answer to
that pause; the phone (decide_save_approval) only wrote it. So a phone
approval sat until someone ran `resume`, and a phone decline left the
pause open while the receipt said the run would continue.

The carry step moves to approval_mode/durable_carry.py, and both the
CLI and the decision portal call it right after writing the answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves item_approval.py: keeps the person-signed grant checks from this
branch and the run-start policy pin from #1232. _recorded_policy() now
returns the pinned policy once the run has written one. The pinned-policy
test now expects the earlier refusal: a planted policy without a signed
grant can't produce an agent's answer at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abrichr
abrichr merged commit b006615 into main Oct 18, 2026
32 checks passed
@abrichr
abrichr deleted the flow/signed-approvers-phone-approval branch October 18, 2026 08:20
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