ADR-0016: Approval-Time Preview Fetch
Context
Action manifests can gate a connector op behind per-call user approval. Per ADR-0003, the manifest declares this with:
[approval]
required = truePer ADR-0008 and ADR-0009, when the runtime asks the user to approve, the question is rendered on an out-of-band surface the agent cannot reach. Today that surface has only the action call’s inputs to render.
For some actions inputs are sufficient. The send-email action declares to, subject, and body as inputs, and the approval UI can show them directly. For other actions inputs are not sufficient. The send-draft action in aileron-connector-google takes a single input, draft_id, which is opaque to the user. Approving “send draft r-OR0123456789” gives the user no signal about who the mail is going to, what the subject is, or what the body says.
One path that does not work is to let the agent populate the prompt. The send-draft manifest currently suggests:
The approval prompt should surface the draft id (and ideally the agent should pass through a recent
draft_emailresponse so the user recognizes what they are sending).
The “agent passes through a recent response” path is forgeable. The agent supplies the preview text, so the prompt’s content is only as trustworthy as the agent. For a non-reversible op (a dispatched mail cannot be unsent), the approval payload cannot rest on agent honesty. It must come from the same authoritative source the op will write to.
ADR-0009 makes this constraint structural. The approval surface is the user’s escape hatch when the agent is compromised, misaligned, or wrong. Letting the agent populate that surface defeats the purpose.
Decision
Extend the action manifest’s [approval] block with an optional preview directive. The directive names a read-only op the runtime calls before showing the approval prompt. The preview op’s response is rendered in the prompt. It is never returned to the agent.
The preview directive controls how external state fetched at approval time is shown to the user. It composes with the per-input display metadata defined by ADR-0003 (label and multiline on [[inputs]]), which controls how the agent’s call-time args are rendered. An action can declare both: the preview block surfaces the remote object being acted on (the calendar event being updated, the email being drafted against), and the input metadata surfaces what the agent is about to write.
[approval]
required = true
[approval.preview]
# Connector op to call. Must live on the same connector as the gated
# op, must be declared idempotent in some action manifest, and must
# not itself carry [approval].required = true (no recursion).
op = "get_draft"
# Argument template. Values may interpolate ${args.<name>} from the
# gated action call's inputs, matching the existing interpolation
# convention used in [[execute]] steps.
args = { id = "${args.draft_id}" }
# Fields to render in the approval prompt. Keys are user-facing
# labels; values are dotted paths into the preview op's JSON response.
# Missing paths are omitted with a per-field "n/a" indicator.
render = { To = "message.payload.headers.To",
Subject = "message.payload.headers.Subject",
Body = "message.snippet" }
# Labels in `render` whose values are long-form content. The approval
# UI surfaces these as scrollable blockquotes below the inline rows
# rather than as single-line key/value entries, so the user can read
# the whole body of (e.g.) a draft email before approving. Optional;
# omit for prompts whose fields are all short.
multiline = ["Body"]The runtime’s approval flow becomes:
- Agent calls the gated op.
- Runtime resolves
[approval.preview]. If present, the runtime validates the preview op against the rules below, invokes it in the same WASM sandbox with the same credential binding the gated op would use, and extractsrenderfield paths into a{label: value}map. On failure the runtime records the error and falls back to rendering raw inputs with a “preview unavailable:<reason>” line. - Show the approval prompt with the preview map (or fallback) alongside the gated op’s inputs.
- On approval, invoke the gated op as normal.
- On denial, skip the gated op as normal.
Manifest validation rules
The runtime rejects an [approval.preview] block at manifest-load time if any of the following hold:
opdoes not exist on the same connector as the gated op.- The
opnamed is not declaredidempotent = truein at least one bundled action manifest. - The
opnamed appears in any action manifest with[approval].required = true. This prevents preview-of-preview recursion. argsreferences an${args.X}that the gated action does not declare as an input.renderis empty.- An entry in
multilinedoes not appear as a key inrender.
These checks happen at suite load and at release-time signing, so a malformed preview directive fails fast.
Runtime semantics
Same sandbox. The preview op runs through the same aileron_host.http_request boundary, with the same [capabilities.network] enforcement and the same OAuth token injection. Per ADR-0005, the connector still never sees the token.
Same credential. The preview op uses the connector’s bound credential. There is no scope expansion or re-verification. If the gated op is allowed to dispatch, the preview is allowed to read the same draft.
Agent isolation. The preview response is rendered only to the approval surface. It is not returned to the agent’s context. Otherwise preview becomes a side-channel. An agent could request approval purely to learn the contents of a draft it has no business reading.
Quota cost. The preview op consumes one API call against the upstream provider per approval. For Gmail’s users.drafts.get?format=metadata this is a single quota unit, cheap relative to the dispatch.
Timeout. The runtime enforces a short bound on the preview call (suggested: 5s) and falls back to “preview unavailable” on timeout rather than blocking the user indefinitely.
Caching. Preview responses are not cached. If the user is re-prompted for the same approval (for example after a denied retry), the runtime re-fetches. This keeps the preview faithful to current state. A draft the user edited in Gmail between prompts shows the edited version.
Field-path resolution
The render values are dotted JSON paths into the preview response. For Gmail’s draft shape, headers live in an array (message.payload.headers: [{name, value}, ...]), so the path message.payload.headers.Subject is shorthand. It means “find the entry in message.payload.headers whose name equals Subject, return its value”. The runtime implements this shorthand for header-style arrays. For other shapes the path is a plain object/array walk.
Multiline fields
A field whose value is long-form content (an email body, a commit message, an HTTP response payload) reads poorly as a single key: value row. The optional multiline list names labels in render that the approval surface should treat as block content rather than inline key/value entries.
The semantic guarantee is purely user-visible: the runtime resolves each label’s value the same way regardless of whether the label appears in multiline. The flag is a render hint the approval prompt honors. The webapp renders multiline labels as scrollable blockquotes below the inline rows; CLI surfaces are free to render them with a header line and an indented block, or any equivalent affordance.
multiline is optional. A manifest that omits it produces the prior all-inline rendering. A label that appears in multiline but not in render is rejected at manifest-load time, matching the validation discipline applied to args interpolation. The flag does not change the failure-mode contract: a multiline label whose path resolves missing surfaces as “n/a” the same as any other field.
Failure modes and fallbacks
| Failure | Behavior |
|---|---|
| Preview op HTTP non-2xx | Render fallback (“Preview unavailable: Gmail returned 404”). Approval still proceeds. |
| Preview op timeout | Render fallback (“Preview unavailable: timeout”). Approval still proceeds. |
render path missing in response | Omit that field; surface a per-field “n/a” indicator. Approval still proceeds. |
| Preview op denied at sandbox boundary | Manifest validation should have caught this. If it slips through, render fallback and log. |
| Preview op crashes (WASM trap) | Render fallback. |
In every failure mode the approval still proceeds. The user is asked to approve based on raw inputs plus a clear “preview unavailable” note. The runtime never silently degrades to “approved without preview”. The user sees that preview failed and can decline.
Security model
The connector is hash-pinned per ADR-0002. A connector could lie to the preview as it could to any op. That is the existing trust model. The audit trail (ADR-0010) catches divergence after the fact, and the user can deny if the rendered preview looks wrong.
Preview output is never returned to the agent, so it cannot be used as a data-exfil channel.
The preview op runs with the same network and credential capabilities as the gated op, with no expansion. A connector cannot use the preview slot to reach a new host.
Manifest validation happens at signing time. A release that declares a preview op pointing to a non-idempotent or approval-gated op fails to publish.
Worked example: send-draft
Connector ALRubinger/aileron-connector-google ships a new read-only op get_draft (calling users.drafts.get?format=metadata) and a matching actions/get-draft manifest with idempotent = true and no [approval] block.
The send-draft action manifest is updated:
[approval]
required = true
[approval.preview]
op = "get_draft"
args = { id = "${args.draft_id}" }
render = { To = "message.payload.headers.To",
Subject = "message.payload.headers.Subject",
Body = "message.snippet" }
multiline = ["Body"]When the agent calls send_draft with draft_id = "r-12345":
-
Runtime sees
[approval.preview]and callsget_draft({id: "r-12345"}). -
Gmail returns the draft. Runtime extracts To, Subject, and snippet.
-
The approval prompt renders:
Approve send-draft? Draft id: r-12345 To: [email protected] Subject: Weekly recap Body: > Here's the recap from this week's standup... > <full body rendered as a scrollable block per the > manifest's `multiline = ["Body"]` directive> [Approve] [Deny] -
The user approves, and
send_draftdispatches. -
The user denies, and
send_draftreturns the standard denial error. No message is sent. The audit log records the deny.
If get_draft returns 404 (the agent passed a message id instead of a draft id, the exact bug that motivated this ADR), the prompt renders:
Approve send-draft?
Draft id: 19df4136f28569d2
Preview unavailable: Gmail API returned 404
[Approve] [Deny]
The user sees that something is off before approving, instead of silently approving a doomed call.
Alternatives considered
Agent-supplied preview inputs. Add optional to_preview, subject_preview, body_preview inputs to send-draft. Cheap, works today. Rejected as the load-bearing solution. The values are forgeable, and the entire point of approval is to not trust the agent on the question of “what am I about to do.” This path could still be allowed as a fallback hint when no preview op is declared.
Make send-draft take the draft contents instead of an id. Defeats the purpose of the draft-then-send split. The agent reconstructing the RFC 2822 payload is what draft-email already avoids.
Runtime-side Gmail integration. The runtime fetches the draft directly without going through the connector. Rejected. It special-cases a single provider, breaks the connector boundary, and would need to be re-implemented per provider.
Open questions
Cross-connector previews. Should a preview op be allowed to live on a different connector than the gated op? The argument for is composability. The argument against is trust-boundary muddiness. Initial proposal says no. Revisit if a real use case appears.
Preview re-render on edit. If the user edits the draft in Gmail between the agent’s send_draft call and the approval prompt, the runtime sees the edited version. Good. If they edit after the prompt renders and before they click approve, the prompt is stale. Should the prompt poll? Initial proposal: no. The latency window is short and polling adds quota cost.
Preview output retention. Are preview responses logged in the audit trail? The argument for is completeness. The audit shows what the user saw. The argument against is PII/content leakage into logs. Initial proposal: log a hash of the preview output, not the output itself.
Migration
Existing [approval] blocks without preview continue to work unchanged.
Connectors opt in by adding a read-only op and updating the gated action manifest. The opt-in is per-action.
aileron-connector-google ships get_draft and the get-draft action manifest in the same release as the runtime support. The send-draft manifest gets updated to reference the preview op once the runtime version enforcing the new schema is the minimum supported.
References
- Issue #658 ↗ — this proposal
- ADR-0002 — Connector Model (hash-pinning, capability boundary)
- ADR-0003 — Action Model (
[[inputs]],${args.X}interpolation,[approval]block) - ADR-0005 — Sandbox Choice (credential mediation)
- ADR-0008 — Action Exposure to Agents (single execution path through
POST /v1/actions/{name}/run) - ADR-0009 — User Channel and OOB Approval Surfaces (agent never in trust path)
- ADR-0010 — Failure-Handling Policy (audit store)