> ## 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 CRM-update agent

> An agent updates customer records from conversations: a field update runs on its own, merging two records waits for a human, deleting a record is refused.

A support agent keeps the CRM current from conversations. Updating a phone number is cheap to
undo. Merging two customer records is not, so it waits for a person. Deleting a record destroys
the thing every receipt points at, so an agent never does it.

## The policy

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

actions:
  crm.update_record:
    effect: "crm:{record_id}:{field}:{revision}"
    resource: "record:{record_id}"
    decision: allow
  crm.merge_records:
    effect: "merge:{keep}:{drop}"
    decision: approve
  crm.delete_record:
    decision: deny
```

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

records: dict[str, dict[str, str]] = {"c_1": {"phone": "+353 1 555 0100"}, "c_2": {}}
writes = 0


def crm_update(record_id: str, field: str, value: str) -> str:
    global writes
    writes += 1
    records[record_id][field] = value
    return "updated"


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


@ctrlrun.protect(
    "crm.update_record",
    effect="crm:{record_id}:{field}:{revision}",
    resource="record:{record_id}",
    control=control,
)
def update(record_id: str, field: str, value: str, revision: int) -> str:
    return crm_update(record_id, field, value)


@ctrlrun.protect("crm.merge_records", effect="merge:{keep}:{drop}", control=control)
def merge(keep: str, drop: str) -> str:
    return "merged"


@ctrlrun.protect("crm.delete_record", control=control)
def delete(record_id: str) -> str:
    return "deleted"


with ctrlrun.context(agent="support-agent"):
    print(
        "update phone:", update(record_id="c_1", field="phone", value="+353 1 555 0199", revision=7)
    )

    # A second worker handling the same conversation proposes the same revision.
    try:
        update(record_id="c_1", field="phone", value="+353 1 555 0199", revision=7)
    except ctrlrun.DuplicateEffect:
        print("same update from a second worker: refused, already applied")
    else:
        raise SystemExit("one revision was written twice")

    # A later revision is a new effect and runs.
    print(
        "update phone, revision 8:",
        update(record_id="c_1", field="phone", value="+353 1 555 0200", revision=8),
    )

    try:
        merge(keep="c_1", drop="c_2")
    except ctrlrun.ApprovalRequired as pending:
        print("merge c_2 into c_1: a human decides:", pending.request_id)
    else:
        raise SystemExit("a merge ran without a human")

    try:
        delete(record_id="c_2")
    except ctrlrun.ActionDenied:
        print("delete c_2: refused")
    else:
        raise SystemExit("a record was deleted")

print("CRM writes:", writes)
```

## What the agent sees

```text theme={null}
update phone: updated
same update from a second worker: refused, already applied
update phone, revision 8: updated
merge c_2 into c_1: a human decides: apr_…
delete c_2: refused
CRM writes: 2
```

The revision number in the effect key is what tells a duplicate from a change: the same
revision twice is one effect; a new revision is a new one.

## The receipt

```bash runnable theme={null}
ctrlrun receipts --last 5
```

## When an AMBIGUOUS appears

Read the record back. If the field holds the new value, `ctrlrun resolve
crm:c_1:phone:8 --committed`; if not, `--failed`. For a merge whose reply was lost, check
which record still exists before resolving: a merge is the one update here that cannot be
undone by another update.

## Next

* [A data-deletion agent under a retention rule](/cookbook/data-deletion-agent).
* [Effect keys](/concepts/effect-keys) · [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [A data-deletion agent under a retention rule](/cookbook/data-deletion-agent.md)
- [Cookbook](/cookbook/index.md)
- [Resolve an ambiguous effect](/cookbook/resolve-an-ambiguous-effect.md)
- [Move from SQLite to Postgres](/cookbook/sqlite-to-postgres.md)
- [The Agent Control Standard](/ACS.md)
