# Outcome: cluster binding

Settled 2026-08-03 over five notes between two reviewers. This is the agreed design and the
reasoning that survived. The notes themselves are kept because the disagreements are the useful
part; this file is so nobody has to read five documents to find out what was decided.

## The principle

**Expose only parameters the caller's context can recover.**

A model fills every field it is shown. If nothing in its input determines the value, it does not
decline, it invents one. A parameter is therefore a question the caller must be able to answer from
what it has, and one that fails that test is a defect in the schema rather than in the model.

This is recorded next to `ToolDefinition.inputSchema` in `packages/core/src/tools/framework.ts`,
where someone adding a field will read it, rather than only here.

The evidence: a templates deployment exposed `clusterId`. An agent asked an ordinary support
question called the tool with `clusterId: "system"`, was refused by the allowlist, and then asked
its user for a `CLUS-1234`, a format that does not exist. Every other argument in the same call was
correct, because every other argument was recoverable from the question.

## The design

The target moves into the template definition. The deployment defines the boundary; the template
defines the destination within it.

- `clusterId` leaves the input schema of template tools. Exploration tools keep it, because there
  the caller genuinely chooses.
- A template definition declares `cluster`, required, next to the SQL that already names its
  database.
- `--cluster-allow` keeps its name and loses a job: it is the boundary, not the implicit target.
  Every declared target is validated against it at boot.

Refusals and readiness:

- **Refuse at boot**: a bring-your-own credential with more than one distinct declared target. The
  BYO path ignores the cluster id entirely, so a boundary declared in configuration would not exist
  in the credential layer. A deployment that appears to enforce isolation without enforcing it is
  worse than one that visibly does not, because the first passes review.
- **Refuse at boot**: a template declares a cluster and the REST deployment has no cluster identity
  to compare it against.
- **Readiness, not refusal**: BYO reachability. Fatal only on errors that retrying cannot fix. The
  discriminator is whether the server answered: an authentication failure arrives as a response, a
  cold start arrives as silence.
- **Deregister** tools bound to an unavailable target where the transport allows it; otherwise
  shape the error so a model can tell that retrying is pointless.

Plus one integration test, described below.

## What the checks actually cover

Stated plainly so nobody later reads them as more than they are.

They catch **partial** misconfiguration: a template file copied onto a deployment whose
configuration was written fresh, where the two assertions disagree or one is missing. They do not
catch a wholesale copy, and the REST check compares an operator's assertion against a template's
assertion without verifying either against reality.

It does not need to verify the host, because the platform does. TiDB Cloud Serverless requires a
per-cluster prefix on the SQL user name, so a credential cannot be pointed at another cluster.
Measured across a different gateway in the same region, a gateway in another region, and with the
prefix removed:

```
ERROR 1105 (HY000): Access denied. ... #user-name-prefix
ERROR 1105 (HY000): Missing user name prefix.
```

That is a property of another product, not of our code, which is why it gets a test rather than a
paragraph: connect to a different cluster's host with credentials belonging to this one and assert
refusal. If that test ever starts passing, the host-swap case is back and we learn it from a red
build.

## Options that were rejected, and why

**A, default the parameter when one cluster is allowed.** Implemented and currently deployed. Too
weak: the field stays visible, so a model can still fill it and still be refused. It makes the
common path work rather than removing the failure. It also overloads a security flag with a
routing job.

**B, remove the parameter and bind one deployment to one cluster.** The recommendation in the first
note. Rejected because it settles a product question we have not answered, namely whether the unit
of tenancy is the organization or the agent, and because the argument for it depended on option D
being buildable while the same note listed D as unverified.

**C, put the cluster id in the agent's instructions.** No code change, repeated per customer, a
20-digit identifier in a prompt.

**D, bind the cluster per connection.** Rejected on credential scope. It requires the server to
mint SQL users for whatever cluster arrives at connect time, so it holds management scope over an
open set for its lifetime. The current deployment holds one fixed SQL user and provably cannot
reach anything else, which is the strongest security property it has.

**An optional `cluster` field.** Proposed as an amendment and withdrawn. The failure it was meant
to fix needs the field to be set, and the case where it is set is the multi-cluster case the field
exists for, so optionality lowered how often the failure was reachable without removing it from
where it lived.

## Status

Not implemented. Option A is what is deployed today, and it is enough for the demo: `clusterId` is
out of the required list on all four template tools and a call that omits it works. Landing this
design is the next piece of work on that branch, and it is not urgent enough to do between now and
a demonstration.

## Addendum, 2026-08-11: the boundary is the platform's, and E is reclassified

Everything above stands as the reasoning at the time. One fact arrived afterwards that changes what
E is FOR, and it belongs here rather than in a fourth reply, because a reader who takes the sections
above at face value will over-weight E.

**TiDB Cloud API keys can be scoped at cluster level, not only project level.** Confirmed by the
person who issues them. That was the open question the note above did not know it had.

Measured on the POC key the same day, which is scoped narrowly:

```
GET  https://api.tidbcloud.com/api/v1beta/projects?page=1&page_size=1
403  {"code":49900003,"message":"CheckApiKeyPermission not pass: identity has no permission"}

GET  https://iam.tidbapi.com/v1beta1/clusters/10036201305668697556/sqlUsers?pageSize=1
200  {"sqlUsers":[...]}
```

The key cannot enumerate the organization and can operate on one cluster's SQL users. Ephemeral
credential minting is `POST /clusters/{clusterId}/sqlUsers`, a per-cluster path, so a key without
rights on a cluster is refused by TiDB Cloud before any of our code is consulted.

### What that changes

**The destination boundary is enforced by the platform, not by us.** A correctly scoped key makes an
invented `clusterId` fail at IAM. That is a stronger claim than `--cluster-allow` can make, because
it survives a bug in our own code. `--cluster-allow` becomes a second line rather than the line.

**E moves out of the security column.** The note above presents E as what stops an agent mutating
the wrong cluster. With a cluster-scoped key, nothing needs to stop it: it cannot. What E still buys
is real but smaller:

- The agent stops inventing values. The observed failure was not a wrong cluster, it was
  `clusterId: "system"` followed by asking a human for a `CLUS-1234` that does not exist. That is a
  usability defect and it remains one no matter how the key is scoped.
- The case where a key legitimately reaches several clusters, which a project-scoped key does. There
  the agent is choosing among valid destinations and the platform cannot help.

**The multi-tenant shape gets more defensible.** One deployment serving many callers, each bringing
their own cluster-scoped key, no longer depends on our allowlist for isolation between or within
organizations. The earlier reading, that a shared deployment reintroduced the cluster-selection
risk, was too strong.

### The residual risk, stated plainly

It depends on whoever issues the key. A customer who issues an organization-wide key gets none of
this, and today the server cannot tell the difference or say so.

That is worth fixing, and the signal already exists: the 403 on `/projects` above IS the evidence
that a key is narrow. The server currently reads that response only as "the org is unknown" and
renders it as doubt. Reading it as "this key cannot see the organization" would let a deployment
report what its credential can actually reach, which turns a configuration nobody can see into one
the server states. Same response, opposite conclusion.

That is the same signal `feat/pinned-connection-notice` handles for the bring-your-own case, and if
both land, they should share one predicate rather than reading the same 403 twice.

## Addendum, 2026-08-11: why REST is single-tenant, and what would change that

A question worth recording because it looks like an inconsistency and is not one. The MCP endpoint
serves many customers from one process; the REST endpoint at `/v1` serves exactly one. Both live in
the same server.

### The cause is the auth model, not the transport

An MCP request carries a TiDB Cloud API key. That key is two things at once: an identity, and the
authority to mint a SQL user. So the server can decide, per request, which database to open.

A REST request carries a static bearer token issued by whoever runs the deployment. It says the
caller may enter; it says nothing about TiDB. There is nothing to route on, so the connection is
made at boot from `TIDB_HOST`, `TIDB_DATABASE`, `TIDB_SQL_USER` and `TIDB_SQL_PASSWORD`, and
`resolveRestOptions` refuses to start without them.

That was the right trade at the time and it bought the connector its best sentence, shown to the
person filling in the connection: the database credentials stay in the deployment and are never
sent from a recipe.

### The objection to per-connection keys was wrong

An earlier version of this reasoning, and the auth-mode table in `DEMO-SCRIPT.md`, rejected
"let the REST caller present a TiDB Cloud key" on the grounds that it hands the integration platform
a key that can create and delete clusters across the organization.

That is true only of a broadly scoped key. Measured 2026-08-11 with the key this deployment uses:

```
GET  https://api.tidbcloud.com/api/v1beta/projects?page=1&page_size=1   403  no permission
GET  https://serverless.tidbapi.com/v1beta1/clusters?pageSize=50        200  1 cluster
```

Asked at organization scope for up to fifty clusters, the key returns one. It cannot enumerate the
organization at all. Key permissions are per key, set when the key is issued, and this one is narrow.

### So the shape to build, when REST needs to serve many

The caller presents a scoped TiDB Cloud key instead of a static token, exactly as MCP already does.

- We hold no customer database password, so there is no store to breach.
- No database password reaches the integration platform, so the connector keeps its promise in a
  different way: the credential it holds is scoped and revocable rather than absent.
- The code exists. This is the MCP path applied to the other router.

The two rejected alternatives stay rejected for the same reasons as before: a token-to-database map
makes the server a credential store, and asking the recipe for host and password puts a database
password in the integration platform.

### The residual risk has a measurement now

It depends on whoever issues the key, and the earlier addendum recorded that the server cannot tell
a narrow key from a wide one. It can:

```
GET https://serverless.tidbapi.com/v1beta1/clusters?pageSize=50
```

That returns exactly what the key reaches. Called once when a connection is created, it lets a
deployment state its own blast radius rather than assuming a configuration it never checked. One
cluster back is the good case and worth naming; ten is worth saying out loud before anyone builds a
recipe on it.

This is the same follow-up the previous addendum described in the abstract, and it is also the same
signal `feat/pinned-connection-notice` reads for the bring-your-own case. If all three land they
should share one predicate.
