Declined Means Stop: An AI Agent Consent State Machine and Fail-Closed Test Matrix
Treat every AI agent consent state except granted as a hard stop. One state machine for banners, GPC, MCP elicitation and OAuth, plus a release test matrix.
Go deeper. Build your own.
The Model Context Protocol gives a user three answers to an elicitation prompt, and the example handling it gives for the second one, decline, is to offer alternatives. That is sensible advice for a form. For an agent holding a shell, a browser and a dozen MCP servers, it is also how a “no” turns into the same outcome reached through a different tool.
This playbook makes AI agent consent fail closed. You get one state machine for site, ad and agent actions, with six states (granted, declined, cancelled, revoked, expired, unknown) and one rule: anything but granted is a typed error and a stop. You also get a test matrix that runs on every release and every consent-tool or tag change, so the rule is proven rather than assumed.
The stop-on-decline rule is deliberately stricter than the MCP guidance, and we say so wherever it matters. This is an engineering test, not legal advice; the rule text and regulators are cited so your counsel can set the legal floor and your tests can sit above it.
Where consent rules stand in October 2026: MCP, California, the EU and Google
The MCP specification, version 2026-07-28, states the principle twice. “Users must explicitly consent to and understand all data access and operations,” and “Hosts must obtain explicit user consent before invoking any tool.” It also names its limit: “MCP itself cannot enforce these security principles at the protocol level.” Enforcement lives in the host you ship.
The elicitation chapter splits refusal in two. Decline means “User explicitly declined”; cancel means “User dismissed without making an explicit choice”, which covers a closed dialog, a click outside, Escape or a browser that failed to load. Servers “MUST handle cases where the user declines or cancels”, and the suggested handling is “offer alternatives” on decline and “prompt again later” on cancel. Payment and credential flows must use URL mode.
Screenshot: Model Context Protocol, “Elicitation - Model Context Protocol” (specification version 2026-07-28), captured Oct 5, 2026.
California keeps tightening the web side. At its Aug 6, 2026 board session on opt-out preference signals, the California Privacy Protection Agency set out that on a signal, businesses must “stop selling and sharing any personal information associated with that browser or device”, and that the Opt Me Out Act requires browsers to offer such a signal from Jan 1, 2027. Enforcement this year punished half-honored opt-outs: per Potomac Law’s summary, the Attorney General’s $2.75M action against Disney (February) turned on a GPC signal honored on one device only and ad-tech pixels that kept firing after a webform opt-out, and a $1.1M PlayOn Sports action followed in March.
One scope correction: California’s updated CCPA regulations add automated decision-making technology duties “If a business uses ADMT to make significant decisions about a consumer”, with compliance from Jan 1, 2027. Significant decisions mean things like jobs, lending and housing. A coding agent refactoring your repo is not that, so don’t let ADMT stand in for the consent work below.
The EU moved dates, not consent. The Digital Omnibus on AI, in force since Jul 27, 2026, pushed Annex III high-risk obligations to Dec 2, 2027 and Annex I to Aug 2, 2028; Article 50 already applies (see EU AI Act Article 50 is live). GDPR consent is unchanged. The separate cookie package is still a proposal: Osborne Clarke describes a single-click refusal with a six-month no-re-ask (draft Art. 88a), and Nixon Digital reported in August that the Council dropped the browser-signal article (88b).
Google consolidated its controls. Per Google Analytics Help, “starting June 15, 2026, Google Analytics will transition to using Consent Mode (within Google Ads) as the single control for data”. An ads-personalization change is promised “later in 2026”, with dates to be “shared later this year”; none had been published by Oct 5.
Screenshot: Google Analytics Help, “Updates to Google Analytics Data Controls - Analytics Help” (June 15, 2026 change), captured Oct 5, 2026.
Why agents make “offer alternatives” dangerous
A web form that hears “no” can show a different form. An agent that hears “no” has other tools: if the GitHub MCP server’s write is declined, the shell can still run git push, and the browser can still click the merge button. Offering alternatives, read literally by a planner, is a route around the human.
Cancel is the quieter trap. A dismissed dialog, an Escape key and a failed page load all produce the same cancel, so the agent cannot tell “not now” from “never saw it”, and generic retry middleware turns it into a nag loop. Revocation must reach work already in flight. Decide the fail mode before the outage, the discipline of writing a gate’s fail mode before it returns 429.
The runbook: build the consent state machine, then prove it with a test matrix
Six steps. The first three define and wire the machine; the last three test it and keep testing it.
Step 1: Define six states and one rule
Every consent source in your product resolves to exactly one of these states at the moment of an action.
| State | Means | Typical sources | What the system does | Path back to granted |
|---|---|---|---|---|
| granted | Explicit yes, in scope, not expired | Banner accept, elicitation accept, OAuth grant, OS permission allowed | Action runs within the granted scope | n/a |
| declined | Explicit no | “Essentials only”, elicitation decline, OS permission denied, opt-out signal | Typed error, stop, no alternative path to the same outcome | Only a new grant the user starts |
| cancelled | Dismissed without a choice | Elicitation cancel: closed dialog, Escape, load failure | Typed error, stop the current action | One re-ask, at a human checkpoint |
| revoked | Was granted, then withdrawn | OAuth revocation, banner changed, telemetry turned off | Stop in-flight work at the next tool boundary, purge queued sends | Only a new grant the user starts |
| expired | Grant past its lifetime | Token expiry, consent record older than your re-ask period | Typed error, stop | Re-ask at a human checkpoint |
| unknown | No usable record | Consent service down, banner failed to load, unparseable record | Typed error, stop; never default to granted | Once a valid record exists |
The rule: the action proceeds only in granted. Every other state raises the same error type and stops. Here is where the rule departs from MCP on purpose. The spec suggests offering alternatives on decline; we forbid any alternative that reaches the same outcome. The spec suggests prompting again later on cancel; we allow one re-ask, at a checkpoint a human is already looking at, never from a retry loop.
Five of six states end in the same stop. Only an explicit accept reaches granted.
Give the stop one error shape everywhere, so a tag manager, a server sender and an agent host can all log and test it the same way. An illustrative shape:
{
"error": {
"type": "consent_not_granted",
"state": "declined",
"source": "mcp_elicitation",
"scope": "repo.write:payments-service",
"blocked_action": "create_pull_request",
"retryable": false,
"halt_session": true,
"audit_id": "cns_20261005_0142"
}
}
Step 2: Map every consent source onto the machine
Write this table into the repo next to the code that reads each signal. A source with no row is a source nobody tests.
| Source | Signal you read | State | Where it’s enforced |
|---|---|---|---|
| Cookie banner “Essentials only” | Consent record with ad and analytics categories off | declined, per category | Tag manager and server sender |
| Global Privacy Control | Sec-GPC: 1 request header |
declined for sale and sharing | Edge or server middleware, per request |
| Google Consent Mode | ad_storage, ad_user_data, ad_personalization denied |
declined for ads | Tag manager, and mirrored server-side |
| MCP elicitation decline | action: "decline" |
declined | Agent host |
| MCP elicitation cancel | action: "cancel" |
cancelled | Agent host |
| OAuth grant revoked | Revocation event, or refresh fails with invalid_grant |
revoked | Agent host and token broker |
| OS permission denied | Screen, files, accessibility or camera prompt answered no | declined | Desktop app |
| Telemetry opt-out | In-app setting turned off | revoked if it was on, else declined | App and telemetry backend |
| Consent store unreachable | Timeout or parse error | unknown | Everywhere |
Two notes. GPC arrives per request and per browser, so honor it where the request lands and propagate it to the account if the user is signed in; the Disney case turned on a signal honored on one device only. And server-side ad events need the same state as the tags: a Pixel revoke does not stop a server sender. OpenAI’s Ads Conversions API docs put the rule plainly, “If the user revokes consent, stop sending it”, and the install-attribution ledger enforces it with a gate in front of every send.
Step 3: Make the stop real at each layer
A consent rule that lives in one layer fails open in the others. Wire the same state into all five.
- Tags. Default every non-essential tag to denied. Nothing fires until a granted record exists for its category.
- Server senders. Read the same consent store as the tags. No record or a non-granted record means no event, logged with a reason.
- Agent tool calls. The host checks consent before invoking any tool, because the protocol cannot. A non-granted state returns the typed error instead of calling the tool.
- Sessions. On decline or revocation mid-session, halt at the next tool boundary and surface the session to a human. Interrupt semantics matter here: stop at a boundary, don’t orphan half-applied work.
- Retry policy. Mark
consent_not_grantednon-retryable in every retry wrapper, and forbid the planner from choosing another tool for the same blocked outcome.
An illustrative host policy:
consent_policy: # illustrative host policy, not a spec
default_state: unknown
proceed_only_when: granted
on_not_granted:
error_type: consent_not_granted
retryable: false
halt_session: true
surface_to_human: true
alternative_tools_for_same_outcome: forbidden
per_state:
cancelled: { reask: next_human_checkpoint, max_reasks: 1 }
expired: { reask: next_human_checkpoint, max_reasks: 1 }
revoked: { stop_at_next_tool_boundary: true, purge_queued_sends: true }
audit:
sink: append_only_log
fields: [ts, state, source, scope, blocked_action, session_id, actor]
The “same outcome” clause is the one teams skip. Define outcomes, not tools: “push to the payments repo” is blocked whether the attempt comes from an MCP server, a shell command or a browser click.
Step 4: Build the test matrix
Rows are consent sources; columns are the behaviour each must produce. “Must” is a pass condition; “n/a” means the layer isn’t involved for that source.
| Source (state) | No tag fires | No server-side event | Tool call aborted with typed error | Session halts, human sees it | No silent retry | Audit line written |
|---|---|---|---|---|---|---|
| Banner “Essentials only” (declined) | Must | Must | n/a | n/a | Must: no re-prompt on the next page | Must |
| GPC header (declined) | Must, sale and share tags | Must | n/a | n/a | Must | Must |
| MCP elicitation decline (declined) | n/a | Must | Must | Must | Must: no alternative tool | Must |
| MCP elicitation cancel (cancelled) | n/a | Must | Must | Must | Must: at most one re-ask at a checkpoint | Must |
| OAuth grant revoked (revoked) | n/a | Must | Must | Must | Must: no re-auth loop | Must |
| OS permission denied (declined) | n/a | n/a | Must | Must | Must | Must |
| Telemetry opt-out (revoked) | Must | Must | n/a | n/a | Must | Must |
| Consent store down (unknown) | Must | Must | Must | Must | Must | Must |
How to drive each row in CI:
- Banner and Consent Mode. A headless browser clicks “Essentials only”, loads three pages and asserts that the network log holds no ad or analytics requests and that the server sender logged zero sends for the session.
- GPC. The same browser run with
Sec-GPC: 1set, signed in and signed out. Then sign in on a second browser without the header and assert the account-level opt-out still holds. - Elicitation. A test MCP server that answers every elicitation with
decline, then withcancel. Assert the typed error, the halted session, no second tool touching the same outcome, and at most one re-ask for cancel. - OAuth revocation. Revoke the grant through the provider’s API in the middle of a scripted session. Assert the next tool call fails closed and nothing queued is sent afterwards.
- OS permission. A manual row on a test machine per OS release; record the build and the result.
- Telemetry opt-out. Toggle off, generate activity, and assert the telemetry backend received nothing after the toggle timestamp.
- Unknown. Point the consent client at a dead endpoint and assert every layer stops.
The elicitation row, as an illustrative test against a fake MCP server:
def test_elicitation_decline_stops_the_session(host, fake_mcp):
# illustrative: the fake server declines every elicitation
fake_mcp.answer_elicitations_with("decline")
result = host.run_task("open a pull request on payments-service")
assert result.error.type == "consent_not_granted"
assert result.error.state == "declined"
assert result.error.retryable is False
assert result.session.halted and result.session.surfaced_to_human
assert not host.tool_log.touched("payments-service", after=result.error.ts)
assert host.audit_log.has(result.error.audit_id)
Copy it for cancel and change two assertions: the state, and a check that at most one re-ask reached a human checkpoint.
Step 5: Run it on releases, on consent-tool changes and on the calendar
Run the matrix on change, and add dated reruns for the days the outside floor moves.
| Trigger | What to rerun | Owner |
|---|---|---|
| Every release | Full matrix | Release manager |
| Consent platform, banner or tag change | Banner, GPC, Consent Mode and telemetry rows | Web owner |
| New MCP server, tool or OAuth scope | Elicitation, OAuth and unknown rows | Agent platform owner |
| Agent host or SDK upgrade | Full matrix | Agent platform owner |
| New OS release on a supported desktop | OS permission row | Desktop owner |
| Jan 1, 2027 (Opt Me Out Act, CCPA ADMT compliance) | GPC row, against counsel’s updated floor | Privacy owner |
| Google publishes the ads-personalization date | Consent Mode row | Web owner |
Dates that move the floor under your consent tests. Spacing is not to scale.
Each pass needs a time to stop, not only a yes. Set yours per layer: tags before the next page load, server senders before the next batch, agent actions before the next tool call. California’s regulations (11 CCR § 7026(f)) require a business to stop selling and sharing “as soon as feasibly possible, but no later than 15 business days from the date the business receives the request”; treat that as the legal ceiling, not your target.
Step 6: Read the matrix as a release gate
- Any “Must” cell that fails blocks the release. No waivers for consent rows.
- Store results with the build ID, the consent-platform version and the MCP spec version you tested against.
- Diff the source table in Step 2 against last release. A new source with no matrix row fails the gate.
- Review the audit log weekly: blocked actions and audit lines should match one to one.
The consent checklist
- Six states defined; one error type; granted is the only state that proceeds.
- Every consent source mapped to a state in the repo.
- Tags default to denied; server senders read the same store.
- Agent host checks consent before every tool call.
- “Same outcome” blocked across tools, not only the declined tool.
-
consent_not_grantednon-retryable everywhere; one re-ask max, at a checkpoint. - Revocation reaches in-flight sessions and purges queued sends.
- Matrix runs in CI on release and on consent-tool changes, plus dated reruns.
How fail-closed consent quietly fails open, and the signal for each
Unknown treated as granted. The banner script fails to load and the tag manager’s default is “fire”. Signal: ad requests in sessions that have no consent record at all.
One device, one browser. GPC is honored in the browser that sent it while the signed-in account keeps sharing elsewhere, the pattern in the Disney case. Signal: opt-out rates that differ sharply by device for the same accounts.
Server side never heard. The Pixel pauses on revoke and the server sender doesn’t. Signal: server events exceed consent-granted records for the period.
Decline routed around. The planner treats “offer alternatives” as an instruction and reaches the same outcome with the shell. Signal: after a decline, the same repo, file or account touched by a different tool in the same session.
Cancel as a nag loop. Retry middleware re-asks every few seconds. Signal: more than one prompt for the same scope within minutes; rising accept rates right after repeated prompts, which is the approval fatigue pattern arriving through consent.
Revocation that waits for the next session. The token is revoked but the running session holds a cached client. Signal: tool calls under a revoked scope after the revocation timestamp.
Silent drops with no audit. The stop works but leaves no trace, so nobody can prove it. Signal: blocked-action counts that don’t match audit-line counts.
Consent states are fleet policy, not a per-agent setting
A fleet with ten agents and three consent-handling styles has no consent policy; it has ten. The same argument that made permission modes fleet policy applies here: define the states once, enforce them in every host, and audit them across the fleet rather than agent by agent. The matrix is how you prove the policy holds on a Tuesday, not only on the day it was written.
The machine also carries into money and measurement. A checkout mandate is a granted state with a cap and an expiry, which is how the pre-authorization checklist for checkout agents treats it, and an ad event is only sendable from a granted record, which is the gate in the install ledger. Three teams, one state table.
FAQ
What should an AI agent do when a user declines consent?
Stop the action and return a typed, non-retryable error. Don’t try another tool that reaches the same outcome, and surface the session to a human. The MCP specification suggests offering alternatives on decline; for agents holding shells and browsers, that is how a refusal gets routed around, so this rule is deliberately stricter.
Does the MCP specification enforce user consent?
No. The 2026-07-28 specification says hosts must obtain explicit user consent before invoking any tool, and also that MCP itself cannot enforce these principles at the protocol level. Enforcement belongs to the host application: check consent before every tool call, and treat decline, cancel and unknown as stops.
Does the EU Digital Omnibus change GDPR consent for AI agents?
No. The Digital Omnibus on AI, in force since July 27, 2026, moved EU AI Act high-risk deadlines to December 2027 and August 2028; it did not change GDPR consent. The separate cookie and GDPR proposal, including single-click refusal, is still a proposal, and the Council dropped its browser-signal article.
Sources
- Model Context Protocol: Specification, version 2026-07-28
- Model Context Protocol: Elicitation, version 2026-07-28
- California Privacy Protection Agency: Board session materials on opt-out preference signals (Aug 6, 2026)
- California Privacy Protection Agency: CCPA regulations updates (approved Sep 22, 2025; ADMT compliance from Jan 1, 2027)
- EU AI Act Explorer: Digital Omnibus on AI (in force Jul 27, 2026)
- Google Analytics Help: Updates to Google Analytics Data Controls (June 15, 2026 change)
- Potomac Law: California ramps up enforcement of consumer privacy opt-out rights in 2026 (Mar 18, 2026)
- Osborne Clarke: Digital Omnibus reshapes EU cookie rules (Dec 10, 2025)
- Nixon Digital: EU Council drops browser consent signals (Aug 10, 2026)
- OpenAI Developers: Ads Conversions API (current docs)
