# How local versus upstream approval changes agent control

An approval button is not a security property by itself. Its value depends on where the decision is enforced, what the reviewer can see, whose identity the service records, and what happens after a timeout or broken connection.

For an AI agent that calls an API, local approval and upstream approval answer different questions. A local gate can decide whether this agent process may use a credential or send a prepared request. An upstream gate can decide whether the remote system should apply the requested change to its own resource. When an API already asks for confirmation, the right design is rarely "pick the stricter one." Pick the gate that owns the fact under review, then join its evidence to the rest of the action.

The distinction matters because a clean approval screen can hide a large authorization gap. A reviewer may approve a local request for "deploy release 184" while the upstream service receives a reusable token and a different payload. Or the upstream service may record a deployment approval without knowing that an untrusted agent process assembled the request. Both records can be true and still fail to explain the action.

## Local and upstream gates authorize different things

Local approval authorizes capability use before the request crosses the machine boundary; upstream approval authorizes a resource transition inside the service that owns the resource. Treating those as interchangeable loses either caller context or resource context.

A local gate sits beside the agent, credential vault, command runner, or outbound action gateway. It can inspect the executable, code signature, parent process, session start, selected credential, target host, request method, and proposed arguments. It can also keep the secret out of the agent and perform the call itself. That is a strong answer to "should this local process get to exercise this capability now?"

An upstream gate sits at the API provider or a control plane attached to it. It can inspect the current resource version, organization membership, protected environment, service policy, conflict state, and the remote identity of the approver. That is a strong answer to "should this exact remote transition occur under current service state?"

NIST Special Publication 800-207 describes a policy decision point and a policy enforcement point, and argues for moving enforcement closer to the resource so the implicit trust zone stays small. That principle supports upstream enforcement for facts only the resource owner can know. It does not make a local gate redundant. A service cannot inspect a local process identity unless the client conveys and binds that identity, and most bearer token calls identify the token holder rather than the process that caused the call.

OpenSSH makes the local case concrete. The OpenBSD ssh-add manual says the -c option requires confirmation before an agent uses an added identity. The remote SSH server still performs its own authorization after that signature. One gate asks whether the local client may use the key; the other asks whether the resulting authentication satisfies server policy. Calling either one a duplicate misses the boundary each protects.

The practical rule is simple: approval must be enforced by the component that can prevent the action it describes. A local notification that cannot withhold the credential or call is theater. An upstream comment recorded after a mutation is review, not authorization.

## The matrix starts with the fact under review

Choose the primary gate by scoring what it can know and enforce, not by counting prompts. The table below is the decision matrix I use before adding any approval workflow.

<table>
<thead><tr><th>Criterion</th><th>Local approval</th><th>Upstream approval</th><th>Design consequence</th></tr></thead>
<tbody>
<tr><td>Latency</td><td>Usually one local interaction plus call time</td><td>Includes network, provider queue, notification, and reviewer time</td><td>Prefer local for frequent, reversible calls when local context is sufficient</td></tr>
<tr><td>Agent context</td><td>Can bind process, session, executable authority, tool call, and local user</td><td>Usually sees a token, app, workload, or service account</td><td>Keep a local gate when process provenance changes the decision</td></tr>
<tr><td>Resource context</td><td>Sees only fetched or supplied state, which may become stale</td><td>Can evaluate current version, protection rules, ownership, and conflicts</td><td>Use upstream review for transitions whose safety depends on live remote state</td></tr>
<tr><td>Human identity</td><td>Can bind the person present at the device</td><td>Can bind an organization account, team role, or separation of duties</td><td>Use the identity domain that owns accountability, or preserve both</td></tr>
<tr><td>Credential exposure</td><td>Can keep the secret outside the agent and release only an action</td><td>Often starts after the client already possesses usable credentials</td><td>Local enforcement is necessary when secret custody is part of the threat model</td></tr>
<tr><td>Service outage</td><td>Can deny locally and retain a pending intent</td><td>Cannot approve while the service or its approval plane is unavailable</td><td>Define expiry and cancellation; never reinterpret an outage as consent</td></tr>
<tr><td>Local outage</td><td>Blocks gated calls on that device</td><td>May remain available through another trusted client</td><td>Decide whether alternate clients are allowed or are bypasses</td></tr>
<tr><td>Audit detail</td><td>Can record prompts, denials, process exits, and attempted calls</td><td>Can record accepted requests and authoritative resource changes</td><td>Join both records with a stable action identifier</td></tr>
<tr><td>Tamper boundary</td><td>A compromised host may attack local records or UI</td><td>Provider controls the remote record</td><td>Do not ask one log to prove events outside its trust boundary</td></tr>
<tr><td>Coverage</td><td>Can wrap many APIs consistently</td><td>Covers only operations the provider exposes to its gate</td><td>Inventory unprotected paths before relying on upstream approval alone</td></tr>
</tbody>
</table>

Do not turn the table into a point system with a universal winner. Some rows are vetoes. If the agent must never receive an API key, an upstream confirmation does not solve credential exposure, even if it scores better on resource context. If a production deployment requires an approver from an operations group, a local Touch ID prompt from the developer who launched the agent does not satisfy separation of duties.

Classify the action before choosing a pattern. Read only calls with low disclosure risk may need session authorization but no prompt per call. Reversible writes may use a local gate, a short expiry, and an idempotency key. Irreversible or regulated transitions usually need upstream review because the service owns the final state and organization identity. A command that combines secret use with an irreversible transition can warrant both gates, but only if each prompt says what its own decision means.

## Latency includes waiting, expiry, and human recovery

Local approval usually has lower interaction latency, but the meaningful measure is time until a safe, unambiguous outcome. A fast prompt followed by an uncertain retry is not low latency.

For one person supervising an agent on a Mac, a local card can appear while the request context is fresh. The approver can respond immediately, and the gateway can send the call without a second notification round trip. That fits actions such as opening a routine pull request, querying a protected internal API, or running a known SSH command during an attended session, provided the action's consequence is visible locally.

Upstream workflows have more queueing points. The service must create a pending object, select eligible reviewers, send or expose a notification, wait for an organization identity, recheck current policy, and finally apply the transition. That delay is useful when it creates genuine separation of duties or lets a reviewer see authoritative state. It is waste when the same person approves the same payload twice with no new information.

GitHub's documentation for environments provides a useful example. A job that references an environment with required reviewers waits before it starts, and it cannot access that environment's secrets until approval. GitHub also lets an environment prevent self review. Those details make the upstream delay meaningful: the gate controls both service state and secret release, and it can bind a reviewer who differs from the initiator. A local approval cannot reproduce that organization relationship by displaying the reviewer's email address.

Measure at least four intervals in production: intent creation to prompt, prompt to human decision, decision to execution, and execution to authoritative result. Keep denials and expirations in the same data. A median approval time that excludes abandoned requests will flatter a broken workflow.

Prompt frequency also changes behavior. A per call local prompt on a loop of fifty similar reads trains the reviewer to click without reading. Moving all fifty prompts upstream does not cure that failure. Group only actions that share a bounded capability, clear target set, and short lifetime. Keep destructive transitions separate even when batching would be faster.

Set an expiry that reflects how quickly the facts can change. Ten minutes may be excessive for a request built from a rapidly changing branch head and too short for a formal production review. Do not refresh approval silently. If the payload, target version, credential, or eligible reviewer set changes, create a new decision.

## Context determines whether the reviewer can judge

A useful prompt contains the smallest complete set of facts needed for its decision. Local and upstream systems see different halves, so copying one screen into the other rarely works.

The local side should show who is asking in terms the human can verify: executable identity, signing authority where available, parent session, tool name, credential alias, destination, operation, and a readable payload summary. It should distinguish data the gateway observed from text the agent supplied. An agent supplied explanation such as "safe cleanup" is not evidence.

The upstream side should show the authoritative object and proposed transition: repository and environment, account and region, resource version, diff, policy checks, initiator identity, and eligible approvers. It should not pretend to know local provenance when it received only a bearer token.

Bind the approved intent to execution with a digest over canonical action fields. The envelope below is deliberately small enough to implement. The action_id joins systems; the digest prevents a later payload from borrowing an earlier approval.

```json
{
  "action_id": "act_01JQ7M6F4R2K",
  "session_id": "ses_01JQ7KZ9J1AA",
  "caller": {
    "executable": "/usr/local/bin/agent",
    "signing_authority": "Developer ID Application: Example Team"
  },
  "target": {
    "service": "deploy-api",
    "resource": "production/payments",
    "version": "184"
  },
  "request": {
    "method": "POST",
    "operation": "promote",
    "body_sha256": "98b0...e42c"
  },
  "approval": {
    "scope": "single_action",
    "expires_at": "2026-07-24T14:05:00Z"
  }
}
```

The executor must recompute body_sha256, compare the resource and version, confirm the expiry, and consume a single action approval once. If the agent changes one argument after approval, the comparison must fail closed. The upstream request should carry action_id in a provider supported metadata or correlation field when possible. Do not smuggle it into a field that changes business behavior.

A screenshot is weak binding. It can help a human understand a request, but code must enforce the relationship between the approved bytes and the sent bytes. I have seen systems render a friendly summary from one object and execute another object assembled later. Review the serialization path, not just the interface.

## Identity has three actors, not one

An agent action has at least three identities: the process that proposed it, the credential principal that executed it, and the human who approved it. Compressing them into one actor field produces attractive logs that answer the wrong question.

The process identity may include an executable path, hash, signing authority, parent process, agent protocol session, and local operating system user. None of those automatically becomes a remote identity. The API usually sees an OAuth client, service account, deploy key, role session, or user token.

The approving human also lives in an identity domain. Local biometric confirmation can establish that an enrolled person was present at a device, depending on the operating system mechanism, but it may not establish which organization role that person currently holds. An upstream reviewer account can establish team membership and separation from the initiator, but it may know nothing about which local binary requested the action.

Keep all three identities and state the binding method.

<table>
<thead><tr><th>Identity</th><th>Example evidence</th><th>Question it answers</th></tr></thead>
<tbody>
<tr><td>Proposer</td><td>Process signature, executable hash, session ID</td><td>Which running code asked?</td></tr>
<tr><td>Executor</td><td>API principal, SSH public key fingerprint, role session</td><td>Which authority performed the call?</td></tr>
<tr><td>Approver</td><td>Local user presence or upstream organization account</td><td>Which person accepted which risk?</td></tr>
</tbody>
</table>

Bearer credentials make this especially important. If five agent processes share one token, the upstream audit trail may correctly name the token principal and still fail to distinguish the processes. A local gateway can supply that missing provenance, but only if its session records resist casual editing and correlate with the remote event.

Do not label the agent as the approver when a human clicked. Do not label the human as the API caller when a service account executed the request. Record delegation directly: proposer P requested action A, approver H authorized scope S, executor E applied result R.

Separation of duties is an upstream strength when the service owns the group membership and prevents self approval. Local approval is stronger for user presence and process provenance. When policy demands both, require distinct evidence rather than two clicks by the same person on the same device.

## An outage must leave one durable state

Approval systems fail badly when they treat network errors as UI problems. The action needs a durable state machine that survives process exits, lost responses, and service recovery.

Use explicit states such as created, locally_approved, submitted, upstream_pending, executing, succeeded, denied, expired, and unknown. Terminal states must stay terminal. Store every transition with the action digest and time. A local restart may resume observation of an upstream pending request, but it must not create a fresh mutation unless retry rules permit it.

The safe outage rules are strict:

1. If the local gate is unavailable, gated actions do not leave the machine.
2. If the upstream approval plane is unavailable, actions that require it remain pending or expire.
3. If approval succeeds but execution status is unknown, query by action or idempotency identifier before retrying.
4. If the resource changes while approval waits, invalidate the decision or ask the upstream service to reevaluate it.
5. If a reviewer denies or revokes, cancel any pending execution and record whether cancellation reached the service.

Fail open is not a recovery strategy. Teams sometimes add an emergency bypass because the approval service sits on the critical path. A bypass can be legitimate, but it is a separate privileged operation with a named human, narrow scope, short duration, and its own durable record. Hiding it behind "retry without approval" destroys the control exactly when incidents make review most important.

Local and upstream outages are not symmetrical. A local gate may still record a denied attempt while the network is down. The provider cannot record a request it never received. Conversely, the provider may finish an action after the local process crashes, leaving the local record at submitted. Reconciliation must accept that each side has incomplete knowledge.

Design cancellation before launch. Ask whether an approved but queued action can be withdrawn, whether an approval expires while execution runs, and which state wins when denial races with completion. The remote resource state wins for what happened; the approval record still explains whether it was authorized.

## Retries must not spend one approval twice

Approval and execution are separate operations, so a retry can duplicate either one. The highest risk appears when a client receives approval, sends a mutation, loses the response, and sends the mutation again.

RFC 9110 defines an idempotent method by the intended effect of repeating an identical request. It says clients should not automatically retry a non idempotent request unless they know the semantics are idempotent or can determine that the original request was not applied. Approval does not change that rule. A human consenting to one payment, deployment, or deletion did not consent to an unknown number of attempts.

Use an idempotency key generated when the immutable intent is created, not when execution starts. Bind it to the payload digest and preserve it across safe retries. The upstream service should return the original result for a duplicate key or expose a status lookup. If the API offers neither, the gateway should move ambiguous mutations to unknown and require reconciliation instead of guessing.

Keep approval identifiers distinct from idempotency identifiers. An approval may authorize one execution attempt, a bounded set of identical retries, or a session capability. An idempotency key tells the service which mutation should have one effect. Combining both meanings in one token makes expiration, revocation, and investigation harder.

A useful test sequence needs no failure injection framework:

1. Create one approved action with a fixed action_id, digest, and idempotency key.
2. Send it, then cut the client connection after the request body leaves.
3. Restart the local agent and let recovery inspect stored state.
4. Verify that it queries the provider before any retry.
5. Confirm one authoritative resource transition and one linked chain of attempt records.

Run the same sequence when upstream approval is pending and when approval expires during the disconnect. If a restart creates a second prompt with a new identifier, your recovery path has already broken the chain of custody.

For APIs that use a confirmation endpoint, inspect its semantics. A two call pattern such as prepare then confirm is safe only if the confirmation consumes an immutable server object once or applies an idempotency key. If confirm simply repeats client supplied fields, it may be another mutation endpoint wearing a reassuring name.

## Complete audits join intent, decision, attempt, and result

No single log is complete unless one component observes the proposed intent, approval decision, outbound attempt, provider acceptance, and final resource state. In most real systems, completeness comes from joined records across trust boundaries.

The local audit should include rejected and expired requests, because those never appear upstream. It should record process and session identity, the displayed summary version, canonical payload digest, credential alias rather than secret, human decision, outbound attempt, response code, and any uncertainty. The upstream audit should include the authoritative principal, approval object, reviewer identity, policy version, resource version, execution event, and final status.

Use one stable action_id everywhere you control, plus provider event IDs where available. Do not join only on timestamps. Clock skew, batching, and concurrent calls will eventually produce a false match.

This event shape is sufficient for a first implementation:

```json
{
  "event_id": "evt_01JQ7N2AZ8S4",
  "action_id": "act_01JQ7M6F4R2K",
  "phase": "upstream_result",
  "observed_by": "local_gateway",
  "principal": "service-account:deploy-agent",
  "approver": "org-user:release-reviewer",
  "payload_sha256": "98b0...e42c",
  "provider_event_id": "dep_91358",
  "outcome": "succeeded",
  "recorded_at": "2026-07-24T14:02:18Z"
}
```

Hash chaining or append only storage can expose later edits, but neither proves that an omitted event occurred. Coverage tests must compare expected phases for each action class. A read may need intent, local decision, attempt, and response. A protected deployment may also need upstream pending, upstream decision, execution, and final resource version.

AWS CloudTrail documentation illustrates the provider side of identity evidence: its IAM Identity Center events can identify whether a request came from a user, role, federated user, or another service, and some events include Identity Center identifiers. That is useful authoritative evidence, but it still does not reveal which local agent process formed the request unless you propagate correlation data and keep the local record.

Audit the bypass paths as aggressively as the main path. Direct use of a credential, alternate CLI profiles, API endpoints without protection, administrator bypass, and replay of an approved server object can all make a perfect approval log irrelevant. Your coverage statement should say which channels the gate controls, not claim that "all actions are approved" without an inventory.

## Two gates are useful only when they stay independent

Layer local and upstream approval when they control different facts or different people. Remove one when it merely repeats the same decision and adds fatigue.

A strong layered deployment flow might work like this. The local gate verifies the agent process and asks the developer to authorize use of the deployment capability for one immutable release candidate. It submits that candidate without exposing the credential. The upstream environment then asks an eligible operations reviewer to approve promotion based on current checks and protected environment state. The final log connects both decisions to one action and one deployed version.

A weak layered flow shows "deploy production?" locally, then shows the same words upstream to the same person. Neither prompt includes a digest or version, both approvals last indefinitely, and the agent holds the token throughout. The second click adds latency without adding a trust boundary.

The popular recommendation "always approve as close to the resource as possible" is incomplete. It comes from a sound enforcement principle, but it ignores capability custody and local process identity. Use upstream approval for resource truth. Keep local enforcement when you must control which process can invoke the capability or prevent the process from seeing the secret.

Sallyport occupies that local role for HTTP API and SSH actions: the agent connects through its MCP shim, while secrets remain in the encrypted vault and the app executes the action. Its per session authorization and optional approval on every use do not replace an API provider's protected environment or organization reviewer; they add process and credential use evidence on the local side.

Before shipping two gates, write one sentence for each: "This gate prevents X because it alone observes Y and enforces Z." If the sentences name the same X, Y, and Z, consolidate. If either component cannot actually stop the described action, fix the enforcement point before polishing its prompt.

## A deployment walkthrough exposes the missing joins

Consider an autonomous coding agent promoting release 184 to a production environment whose API already requires review. The safe flow preserves one intent across local approval, upstream waiting, execution, and reconciliation.

The agent first proposes a canonical request containing the environment, release version, expected current version, and idempotency key. The local gateway assigns action_id, records the process session and payload digest, and decides whether local approval is required. Approval authorizes only that digest until a short expiry. The gateway then injects the credential and submits the prepared request.

The provider creates a pending deployment object. Its reviewer sees current checks, the protected environment, the exact release, and the organization identity that initiated the operation. Approval consumes that pending object. If production has advanced from the expected version, the provider rejects or reopens review rather than applying stale consent.

Now break the connection after upstream approval. The local side must not return success, and it must not submit another promotion immediately. It stores unknown, queries the provider using the pending deployment ID or idempotency key, and records the authoritative result. If the provider reports success, the local journal closes the existing action. If the provider has no record, the gateway may retry under the original idempotency contract if the approval remains valid.

This walkthrough catches questions that UI reviews miss. Can the agent alter the release after the local click? Can another client confirm the pending deployment? Does upstream denial revoke local authorization? Does a provider administrator bypass appear in the joined record? Can investigators prove which process proposed the release without trusting an agent written label?

Choose the architecture from those answers. Use local approval alone when local capability use is the whole risk and the remote action is routine, bounded, and recoverable. Use upstream approval alone when trusted clients already hold acceptable credentials and only current resource state or organization role can decide. Use both when secret custody or process provenance matters locally and authoritative transition review matters remotely.

Do not promise that two approvals make an action safe. They make two decisions visible. Safety comes from binding each decision to immutable intent, enforcing it at the boundary that owns the fact, handling uncertainty without duplicate effects, and preserving enough joined evidence to reconstruct the result after everyone has forgotten the prompt.
