> ## 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.

# Reconcile against Stripe or Kubernetes automatically

> A reconcile hook per action asks the remote what happened to an effect key, so a lost Stripe reply or a dropped kubectl connection resolves itself.

Two agents, two remotes that can be asked. Stripe can list refunds by payment; Kubernetes
can be read for a deployment's state. A `reconcile` hook per action turns a lost reply into a
question the remote answers, and the record moves only the way the answer points.

## The policy

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

actions:
  stripe.refund:
    effect: "refund:{payment_id}"
    decision: allow
  k8s.scale:
    effect: "scale:{cluster}:{deployment}:{replicas}"
    decision: allow
```

## The code

```python runnable file=main.py theme={null}
import contextlib
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)

stripe_refunds: dict[str, int] = {}  # what Stripe holds
replicas: dict[str, int] = {"checkout": 3}  # what the cluster holds
calls: list[str] = []


def stripe_refund(payment_id: str, amount: int) -> str:
    calls.append(f"refund {payment_id}")
    stripe_refunds[payment_id] = amount  # committed...
    raise TimeoutError("no response from api.stripe.com")  # ...reply lost


def kubectl_scale(deployment: str, count: int) -> str:
    calls.append(f"scale {deployment}")
    raise ConnectionResetError("connection reset by peer")  # never reached the API server


def ask_stripe(effect_key: str) -> ctrlrun.ReconcileOutcome:
    payment_id = effect_key.removeprefix("refund:")
    return "committed" if payment_id in stripe_refunds else "not_executed"


def ask_kubernetes(effect_key: str) -> ctrlrun.ReconcileOutcome:
    _, _, deployment, count = effect_key.split(":")
    return "committed" if replicas.get(deployment) == int(count) else "not_executed"


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


@ctrlrun.protect(
    "stripe.refund",
    effect="refund:{payment_id}",
    reconcile=ask_stripe,
    reconcile_eagerly=True,
    control=control,
)
def refund(payment_id: str, amount: int) -> str:
    return stripe_refund(payment_id, amount)


@ctrlrun.protect(
    "k8s.scale",
    effect="scale:{cluster}:{deployment}:{replicas}",
    reconcile=ask_kubernetes,
    reconcile_eagerly=True,
    control=control,
)
def scale(cluster: str, deployment: str, replicas: int) -> str:
    return kubectl_scale(deployment, replicas)


with ctrlrun.context(agent="ops-agent"):
    try:
        refund(payment_id="txn_9", amount=50000)
    except TimeoutError:
        print("refund txn_9: reply lost; the hook asked Stripe")
    try:
        refund(payment_id="txn_9", amount=50000)
    except ctrlrun.DuplicateEffect:
        print("refund txn_9 again: refused, Stripe has it")
    else:
        raise SystemExit("a refund Stripe already holds ran again")

    try:
        scale(cluster="prod-eu", deployment="checkout", replicas=6)
    except ConnectionResetError:
        print("scale checkout to 6: connection reset; the hook asked the cluster")
    replicas["checkout"] = 6  # the retry succeeds this time
    with contextlib.suppress(ConnectionResetError):
        scale(cluster="prod-eu", deployment="checkout", replicas=6)
    print("scale checkout to 6 again: permitted, the cluster had not applied it")

print("remote calls:", calls)
```

## What the agent sees

```text theme={null}
refund txn_9: reply lost; the hook asked Stripe
refund txn_9 again: refused, Stripe has it
scale checkout to 6: connection reset; the hook asked the cluster
scale checkout to 6 again: permitted, the cluster had not applied it
remote calls: ['refund txn_9', 'scale checkout', 'scale checkout']
```

Two lost replies, two different answers from two remotes, no human in either, and no guess in
either: the hook that could not have answered would have returned `"unknown"` and left the
record where it was.

## The receipt

```bash runnable theme={null}
ctrlrun receipts
```

Each reconciliation is a `RECONCILIATION_STARTED` and `RECONCILIATION_RESOLVED` pair with the
answer, so the log says the record moved because Stripe was asked, not because someone assumed.

## When an AMBIGUOUS appears

With eager reconciliation it appears and is resolved in the same call. It stays only when the
hook answers `"unknown"` or raises, which is the right outcome when the remote itself cannot be
reached: then a person resolves it, and the hook's failure is in the events.

## Next

* [Reconcile automatically](/guides/reconcile-automatically): the three answers and when to run the hook.
* [Outcomes and AMBIGUOUS](/concepts/outcomes-and-ambiguous) · [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [Cookbook](/cookbook/index.md)
- [Reconcile automatically](/guides/reconcile-automatically.md)
- [A refund agent with amount tiers](/cookbook/refund-agent.md)
- [Resolve an AMBIGUOUS effect](/guides/resolve-an-ambiguous-effect.md)
- [Outcomes and AMBIGUOUS](/concepts/outcomes-and-ambiguous.md)
