> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ctrlrun.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# A credential-rotation agent

> An agent rotates API keys: minting the new key runs on its own, revoking the old one waits for a human, and a lost reply is never repeated blindly.

An agent rotates service credentials on a schedule. Minting a new key is safe: nothing uses it
yet. Revoking the old one is the step that breaks a client that has not switched over, so it
waits for a person. And a mint whose reply was lost must not be minted again on a guess, because
each mint leaves a live credential somewhere.

## The policy

```yaml runnable theme={null}
schema: ctrlrun.policy/v2

actions:
  secrets.create_key:
    effect: "key:{service}:{rotation_id}"
    decision: allow
  secrets.revoke_key:
    effect: "revoke:{service}:{key_id}"
    decision: approve
```

## The code

```python runnable file=main.py theme={null}
from pathlib import Path

import ctrlrun
from ctrlrun import Control, Policy, SQLiteStateStore

HERE = Path(__file__).resolve().parent
STATE = HERE / ".ctrlrun"
STATE.mkdir(exist_ok=True)
for name in ("state.db", "state.db-wal", "state.db-shm"):
    (STATE / name).unlink(missing_ok=True)

live_keys: list[str] = ["key_old"]


def mint(service: str, rotation_id: str, lose_reply: bool = False) -> str:
    key_id = f"key_{rotation_id}"
    live_keys.append(key_id)  # the provider has it from here on
    if lose_reply:
        raise TimeoutError("no response from the secrets manager after 30s")
    return key_id


store = SQLiteStateStore(STATE / "state.db")
control = Control(Policy.from_file(HERE / "ctrlrun.yaml"), store)


@ctrlrun.protect("secrets.create_key", effect="key:{service}:{rotation_id}", control=control)
def create_key(service: str, rotation_id: str, lose_reply: bool = False) -> str:
    return mint(service, rotation_id, lose_reply)


@ctrlrun.protect("secrets.revoke_key", effect="revoke:{service}:{key_id}", control=control)
def revoke_key(service: str, key_id: str) -> str:
    live_keys.remove(key_id)
    return "revoked"


with ctrlrun.context(agent="rotation-agent"):
    print("mint 2026-09:", create_key(service="billing-api", rotation_id="2026-09"))

    try:
        revoke_key(service="billing-api", key_id="key_old")
    except ctrlrun.ApprovalRequired as pending:
        print("revoke key_old: a human decides:", pending.request_id)
    else:
        raise SystemExit("a key was revoked without a human")

    try:
        create_key(service="billing-api", rotation_id="2026-10", lose_reply=True)
    except TimeoutError:
        print("mint 2026-10: reply lost")
    try:
        create_key(service="billing-api", rotation_id="2026-10")
    except ctrlrun.AmbiguousEffect:
        print("mint 2026-10 again: refused; a key may already exist")
    else:
        raise SystemExit("a second key was minted for one rotation")

print("live keys at the provider:", live_keys)
```

## What the agent sees

```text theme={null}
mint 2026-09: key_2026-09
revoke key_old: a human decides: apr_…
mint 2026-10: reply lost
mint 2026-10 again: refused; a key may already exist
live keys at the provider: ['key_old', 'key_2026-09', 'key_2026-10']
```

Three keys live, not four: the retry that would have minted a duplicate for the October
rotation was refused. `key_old` is still live because the revocation is waiting for a person.

## The receipt

```bash runnable theme={null}
ctrlrun receipts --last 4
ctrlrun effects --state ambiguous
```

## When an AMBIGUOUS appears

List keys at the provider for the service. If a key for the rotation exists,
`ctrlrun resolve key:billing-api:2026-10 --committed` and use it; if not, `--failed` and mint
again. Never mint on the assumption that the first one did not happen: an orphaned live
credential is the worst outcome here.

## Next

* [An IAM agent that can grant read but never admin](/cookbook/iam-agent).
* [Resolve an AMBIGUOUS effect](/guides/resolve-an-ambiguous-effect) · [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [An IAM agent that can grant read but never admin](/cookbook/iam-agent.md)
- [Cookbook](/cookbook/index.md)
- [Authority and delegation](/concepts/authority-and-delegation.md)
- [Errors](/reference/errors.md)
- [IdentityError](/reference/api/IdentityError.md)
