Policy¶
Policy generation is deliberately separate from code generation. A scope, an approval requirement and a confirmation rule are governance claims; deriving them inside a code template would make them invisible to review.
The output is a policy manifest: one ToolPolicy per tool, each carrying provenance for how
every value was reached, including the reasoning in the rule string.
Least privilege, chosen rather than unioned¶
Ingestion refuses to pick between alternative security requirements. Policy picks, and shows its work.
The subtlety worth knowing: fewest scopes is not the same as least privilege. A scopeless credential such as an admin key looks narrowest by count while granting the most. So scoped alternatives are preferred, and a scopeless winner is reported as a concern rather than accepted.
For the shipped example, three alternatives resolve to:
scopes ['inventory.write']
Narrowest of 3 alternatives, using inventoryOAuth. The union across alternatives
would have granted inventory.admin, inventory.write; rejected
inventoryOAuth(inventory.admin, inventory.write); inventoryAdminKey(no scopes).
An authorization concern is fatal only for a tool that changes state. A read with no declared authentication is an ordinary public endpoint; a write or destructive one with none cannot be shown to be governed, so it fails closed.
The credential, not only the scope¶
A scope says how much access is needed. It does not say what to present at the door, and a
generated server that knows one without the other cannot authenticate at all. The manifest
therefore records required_schemes beside required_scopes: the identifiers of the
alternative that least-privilege selection chose.
The emitted server places each credential where the specification declared it:
| Scheme | Placement |
|---|---|
apiKey |
The header, query parameter or cookie the service named. |
http with scheme: basic |
Authorization: Basic, base64 encoded from a user:password variable. |
http with scheme: bearer |
Authorization: Bearer. |
oauth2, openIdConnect |
Authorization: Bearer. |
| Anything else | Nothing is sent. An unplaceable scheme is reported, never guessed. |
One environment variable per scheme, named <SERVICE>_<SCHEME>_CREDENTIAL and reported by
serve. A variable that is unset is omitted rather than sent empty, so the call fails at the
service as unauthenticated rather than locally as malformed. No credential value is ever
written into a generated file.
Confirmation is bound to the arguments¶
A destructive tool must be confirmed before it runs, and the confirmation is bound to the exact arguments it was given for, with a time to live, so confirming one call cannot authorise a different one. On the SDK 2.x target a person confirms through an MCP elicitation, and the pending call is carried in sealed request state; on the 1.x target the confirmation is a second identical call, which an agent can make itself. Deploying says what each guarantees.
The manifest also records an effect summary written for the person being asked to confirm, and rollback guidance that says plainly when no automated compensation exists.
What else is derived¶
| Field | Derived from |
|---|---|
approval |
Risk class and side effect. |
retry, idempotency_key_required |
Idempotency inferred at ingestion, per RFC 9110 for HTTP methods. |
rate |
Risk class: calls per minute, concurrency, daily budget. |
log_class, sensitivity |
Vocabulary in field names and paths. |
output.max_bytes, redact_fields |
Output ceilings and redaction. |
allowed_environments |
Risk class. |
unresolved |
Everything the compiler cannot demonstrate. |
Redaction decides on the noun¶
Sensitivity is decided on the last token of a field name, the noun the name ends on, rather
than on any overlap with a secret vocabulary. accessToken and clientSecret are secrets;
TitleCaseWordsWithTokenResult is a result, and a live public service returned exactly that.
Redacting on containment removed the service's answer and reported HTTP 200 with no indication
that anything had been withheld.
Retrying, and when not to¶
retry is derived from the idempotency inferred at ingestion, and the generated server acts
on it rather than merely carrying it.
| Policy | Derived when | The server then |
|---|---|---|
safe |
The operation is a read, or is idempotent | Retries up to three attempts in total |
with_idempotency_key |
The operation is not idempotent | Retries, sending an idempotency key |
never |
Idempotency could not be determined | Makes one attempt |
What is retried is deliberately narrow: 429, the gateway codes 502, 503 and 504, and transport failures where nothing was answered at all. A 4xx is the service rejecting the request, and repeating it changes nothing but the load.
500 is never retried, on any policy. A server error may mean the effect happened and the answer was lost, and repeating that is exactly what a retry policy exists to prevent.
The idempotency key is generated per invocation and held across that invocation's retries. A fresh key per attempt would make every retry a new operation, which defeats the point. A key derived from the arguments would make two deliberate identical calls collide, which is worse: the second would silently return the first one's result.
Retry-After is honoured over the backoff curve, because a service that names its window
knows more about it than any curve written here. Attempts are bounded so that a wedged
upstream is reported rather than hammered.
The SOAP path retries on the same transport codes and sends no idempotency key. WSDL declares nothing equivalent, so a key here would be a header this compiler invented that no service was built to honour.
Call budgets, and what enforcing them here can and cannot mean¶
Policy scales three limits by risk, and the generated server counts calls against all three.
| Limit | Read | Destructive |
|---|---|---|
calls_per_minute |
60 | 2 |
max_concurrent |
4 | 1 |
daily_call_budget |
5000 | 20 |
Over budget, a call is refused rather than queued. A queued call looks to an agent like a slow service, and it will wait, retry, or abandon a goal it could have reached. A refusal that names the limit, the number allowed and when it lifts is something an agent can act on:
{
"error": "rate_limited",
"limit": "calls_per_minute",
"allowed": 2,
"retry_after_seconds": 41.2,
"detail": "..."
}
A call that never reaches the service costs nothing. Argument validation and the confirmation gate both run first, so a malformed call or an unconfirmed destructive one does not spend a budget it never used.
The counting is per process. A deployment running several workers needs a shared counter, and a generated server cannot pretend to be one. This is a real limit on what the artifact enforces, and it is stated here rather than left for someone to discover after splitting a service across two replicas.
Annotations a client can check¶
MCP defines readOnlyHint, destructiveHint and idempotentHint, and clients read them to
decide whether to auto-approve a call. The protocol is explicit that they are hints:
annotations are not guaranteed to faithfully describe tool behavior, and clients must treat them as untrusted unless they come from a trusted server
There is no verification mechanism in the ecosystem, no provenance and no attestation. A server asserts them and a client believes them or does not.
This compiler does not assert them. Each is derived from a classification that already carries provenance back to a source pointer, so the question "on what basis does this tool claim to be read-only" has an answer:
| Hint | Derived from |
|---|---|
readOnlyHint |
The risk class, which is what the emission gate acts on |
destructiveHint |
The same, so the hints cannot disagree with the gate |
idempotentHint |
Idempotency inferred at ingestion, per RFC 9110 for HTTP |
x-rotaforge/sensitiveHint |
The policy manifest's sensitivity classification |
x-rotaforge/reversibleHint |
Present only when rollback guidance says an effect cannot be undone |
openWorldHint is absent. It asks whether a tool reaches outside a closed domain, and
nothing in an OpenAPI or WSDL document answers that. One invented value beside three derived
ones is how a set of trustworthy hints stops being checked.
The two extensions are namespaced because both were proposed to the specification and neither was merged. Shipping them unprefixed would claim a standard that does not exist.
Sensitivity is null rather than false when no policy manifest was synthesised. False would assert that a tool touches nothing sensitive, which is a claim, and no claim was made.
What the compiler cannot enforce, it says so¶
Server-side authorization, protection against confused-deputy designs, and end-user identity propagation are properties of a deployed service rather than of a generated artifact. The manifest records them as requirements and never reports them as satisfied.
Recording them is honest and, on its own, useless. Deploying says what to do about each, including the limits on the controls the artifact does enforce.