# Confirmation tokens

Every mutating tool runs a two-step flow: a preflight that takes no action and describes what would
happen, then a confirm that executes it. A confirmation token is the server's signed proof that the
first step actually happened, and what it said.

## The four holes, and which need the network

The flow was already stateless before this: everything the confirm call needs is in its own
arguments, so one server instance can render the preflight and another execute the confirm with no
shared state. What was missing was not distribution.

1. **Nothing proved a preflight was ever shown.** An agent could send `confirm: true` on its first
   call and the server executed. The approval surface the whole design rests on could be skipped,
   and nothing detected it. This is the largest hole and it has nothing to do with scaling.
2. **Nothing prevented replay.** The same confirm arguments worked any number of times.
   `tidb_create_cluster`, `tidb_restore_cluster` and `tidb_restore_backup` each create a NEW
   billable cluster, so a replay spends money.
3. **Nothing bound what was approved to what runs.** If the plan changed between the two calls, the
   human had approved a description that no longer matched.
4. **Nothing expired.** An hour-old confirm was as good as a fresh one.

The signature closes 1, 3 and 4 with no network hop at all. Only 2 needs shared state, so only 2 can
be unavailable. That split is why a ledger outage still leaves three of the four enforced.

## What is in a token

An HMAC-SHA256 signature over a base64url payload, keyed by `CONFIRM_SECRET`. Not encrypted, because
nothing in it is secret; it only has to be unforgeable. The payload carries the tool name, the
resolved target, a hash of the plan, the tenant, issued-at, expiry, and a random nonce.

**The plan hash covers what the operation IS, not how it was displayed:** tool, target, action,
summary, details, reversible. An earlier version hashed the rendered preflight text, which bound the
approval to presentation as well as to substance, and presentation moves on its own. The org banner
is part of that text and the org resolver re-resolves in the background on a five minute retry
window, which is half the token lifetime, so after one failed lookup a preflight could render
"UNVERIFIED" and the confirm two minutes later render a resolved org. Same intent, different text,
legitimate confirm refused. Its only recovery is asking a human to approve the same destructive
action a second time, which is how approval prompts become reflexive.

Details are hashed with object keys sorted recursively, because `JSON.stringify` preserves insertion
order and a plan that builds details conditionally can emit the same pairs in a different order.
Leaves are reduced with the same `String()` the preflight renderer uses, and `undefined` and `null`
are normalized together, so the hash agrees with what was displayed rather than with JSON's rules.
Neither a `BigInt` nor a circular reference can take out a preflight call. The renderer sorts nested
object keys too, so a nested detail displays in the same order it hashes in. Ordering and `undefined` normalization only: the
leaf-to-string reduction is a hash property and stays out of the text, or a nested `2` would display
as `"2"` on the screen a human is asked to scrutinise. The `undefined` half has to be on both sides,
or `JSON.stringify` drops the key from the display while the hash keeps it, and the token binds
something the approver never read.

**One thing this narrows, worth knowing before relying on it:** the hash no longer carries the TYPE
of a detail value. `{v: 1}` and `{v: '1'}` hash alike, as do `{v: true}` and `{v: 'true'}`. Both also
render alike, as `- v: 1`, so the human approved the same thing either way and the token still binds
what was displayed. But do not read the hash as a guarantee about types.

**The rule for anyone writing a `plan()`: bind what the operation will DO, never how the resource
happens to look right now.** Every plan field is hashed, so an unstable value in any of them refuses
a confirm nobody changed. The concrete trap is a lookup that swallows its failure into a
placeholder: a transient 5xx inside the token window changes the hash, and the preflight rendered
during that wobble asks a human to approve a resource it cannot name. Distinguish a fact from a
failure. "The cluster does not exist" is a fact and hashes the same every time; "I could not find
out" is not, and it belongs as an error rather than as `(unknown)`. A genuine rename between the two
calls SHOULD refuse, and does.

The tenant binding matters on its own. Without it, one customer's token would authorize the same
action for another, which is a cross-organization hole of exactly the kind
[org visibility](MULTI-ORG.md) exists to prevent. On the remote the tenant is the value the rate
limiter already judged the request by, read from the request context rather than recomputed.

Verification runs cheapest and most definite first: signature, expiry, tenant, tool, target, plan
hash, and the ledger last. A forged token is decided locally and instantly, so nothing beyond the
signature runs for a request already known to be bad, and a bad token never reaches the database.

**The token is checked before `confirmTarget`.** Not because the mismatch message leaks anything:
the caller is authenticated with their own key and can list their own resources anyway. The reason
is that the token is the cheaper and more definite check, and a caller who has not proven a
preflight happened should get one refusal rather than a diagnostic about a target.

## Which tools require what

**All twelve preflight tools require a token.** The signature costs no network hop, so there is no
reason to spend it selectively, and an earlier draft that required tokens only on the destructive and
billable tools left the largest hole open on `tidb_execute`, which is where `DROP TABLE` travels.

**Seven also spend the token's nonce in the shared ledger**, so the token works exactly once:
`tidb_delete_cluster`, `tidb_delete_branch`, `tidb_delete_sql_user`, `tidb_create_cluster`,
`tidb_restore_cluster`, `tidb_restore_backup`, `tidb_create_backup`. These are the destructive and
billable ones, where a second execution deletes something else or creates another billable resource.

Scoping the ledger to those seven means an outage locks exactly the operations where a double
execution is expensive or irreversible, and leaves everything else working with token enforcement
fully active.

## The ledger

An `INSERT` into `mcp_confirm_nonce` in the control database, the same database as the daily quota
counters and a different table. A duplicate-key error IS the replay signal; there is no
read-then-write, because that has a window where two instances both see "not spent" and both proceed,
which is the double execution the ledger exists to prevent.

**The nonce is spent BEFORE execute.** If execute then fails, the nonce is burned and the operator
re-runs the preflight. The other order leaves a window where a crash between execute and consume
permits a replay, and for a tool that creates a billable cluster a replay is worse than a repeated
approval.

**This fails CLOSED**, and the daily quota in the same database fails open. The contrast is
deliberate: a quota is cost control, and refusing a paying customer over a bookkeeping outage is the
worse failure, while this guards whether an irreversible operation runs twice, and there the worse
failure is doing it twice.

On local `stdio` the ledger is in memory. That is not a compromise there: one process serves every
request, so a map sees every spend.

## Configuration

| Variable                  | Meaning                                                                    |
| ------------------------- | -------------------------------------------------------------------------- |
| `CONFIRM_SECRET`          | HMAC key, 32+ bytes. Scrubbed from logs, including a generated one.        |
| `CONTROL_DSN`             | Control database for the nonce ledger. **Required** for a writable remote. |
| `TIDB_MCP_CONFIRM_TTL_MS` | Token lifetime, default 10 minutes.                                        |

Ten minutes comfortably exceeds the worst per-tenant `Retry-After` from the
[rate limiter](RATE-LIMITS.md), which is bounded by burst over rate and so is seconds. The daily
quota's `Retry-After` is hours and deliberately does not drive this number: a quota-refused confirm
cannot be retried within the day at all, so no TTL fixes that case.

**A remote server with write or admin enabled refuses to start without `CONFIRM_SECRET`.** A
per-process secret would invalidate every outstanding token on restart with no visible symptom. A
read-only remote starts without it, because it has no confirm flow and should not gain a dependency
it cannot use. Local `stdio` generates an ephemeral secret, since a restart there ends the session
anyway.

**A writable remote also refuses to start without `CONTROL_DSN`.** An in-memory ledger is correct
for one process and silently wrong for two: each machine would accept the same token once, so a
destructive or billable operation could run more than once. That is the same class of invisible
breakage as an ephemeral secret, so it gets the same answer.

Rotating the secret invalidates outstanding preflights. That is accepted rather than handled: the
TTL is minutes.

## What an agent sees

The preflight ends with:

```
To proceed, call this tool again with confirm: true, confirmTarget: "prod-db", and confirmToken
from this response.

confirmToken: <token>
(expires in 10 minutes, works once)
```

The token is also in `structuredContent`, so an agent can take it without parsing prose.

Refusals are distinct because the right next move differs:

- **No token:** the preflight was skipped. Run it first. This is the check working.
- **Expired, or already used:** run the preflight again for a fresh token. "Already used" also warns
  that the work may already be done, so a retry is not automatically safe.
- **Plan changed:** something about the operation changed since approval. Deliberately not phrased
  as "the target changed", because the hash covers action, summary, details and reversible too, and
  `wrong target` is its own class. Do not retry; have the user read the new preflight.
- **Ledger unreachable:** retry shortly. Nothing was executed.
- **Wrong tenant or wrong tool:** not a retry case at all.

## Telemetry

Issuance, acceptance, refusal and replay detections are recorded as `confirm` events. Refusals and
replays are security events: never sampled, never dropped, because they are the record that an
approval boundary was tested. The token itself never enters telemetry. The nonce may, since it is
single-use and already spent, and it is what ties a replay detection to the issuance it replays.
