Changing what your agent may do shouldn't be a deploy

Imagine a wealth management firm whose agent takes withdrawal requests. One day someone changes a client's destination account and asks for a withdrawal minutes later, and it turns out to be fraud. Compliance sets a new rule: after a destination change, no withdrawal goes through until the back office has verified the change with the client. How long the rule applies depends on the account's policy.

This is more than a value to update. The firm never stored when a destination changed or when it was verified, and it has no policies to read. If the agent's rules live in its code, this means writing that logic wherever the agent can request a withdrawal, then releasing a new version. If they live in the prompt, you have asked the model to follow the rule, and nothing makes it.

Before the rule applies, someone should be able to answer three questions:

The rest of this piece makes that change in Zarel, on a demo contract in a local stack, on 3 November 2026. The commands and outputs are from that run, trimmed. It is the same demo agent that refuses to give investment advice on our site, extended to record withdrawal requests. It moves no money.

Step 1: add the data

In Zarel, an agent's rules and the data they read are declared in a contract, outside its code. The first publication adds a policy, and the two dates on each account:

- name: withdrawal_policies
  fields:
    - name: name
      type: string
      required: true
    - name: destination_hold_days
      type: number
      required: true
- name: accounts
  fields:
    # the fields it already had, and:
    - name: policy
      type: reference
      references: withdrawal_policies
    - name: destination_changed_at
      type: datetime
    - name: destination_verified_at
      type: datetime
    - name: destination_hold_days
      type: number
      compute:
        expression: policy.destination_hold_days
        strategy: read

No migration, no deploy, and no request is treated differently. The back office fills in the policies and the accounts while requests keep arriving. In this run, five requests for 30,000 were accepted.

The data goes first for a reason: the next step judges past requests, and a request made before these fields existed cannot say what they held.

Step 2: try the rule against what already happened

The rule is a check on each withdrawal request:

- name: recent_destination_change
  on: [ create ]
  effect: deny
  expression: >-
    account.destination_changed_at > now() - duration(account.destination_hold_days, 0, 0, 0)
    and (account.destination_verified_at == null
         or account.destination_verified_at < account.destination_changed_at)
  message: recent_destination_change

A dry run judges the five past requests against it:

$ zarel contract spec dry-run rule.yaml --from ... --to ... --watch

- Sessions evaluated: 5
- Re-evaluated with the same outcome: 3 (60.0%)
- Outcomes changed: 2 (40.0%)

| from     | to      | count | cause                                                                        |
| executed | refused | 2     | Diverged stages: checks; check:withdrawal_requests.recent_destination_change |

Each request, under the rule:

A dry run over a longer window also reaches requests from before the new fields existed. It does not report those as unchanged. It reports that it could not judge them (field_not_declared_then). It also does not run the model again, so it cannot say what the agent would have proposed under the new rule.

Step 3: someone else approves it

In this run the contract names who approves a change to its entities, so publishing creates a proposal and nothing of it applies yet:

$ zarel contract spec apply rule.yaml

"publication": {
  "status": "proposed",
  "requirements": { "entities": "four_eyes" }
}

While it waited, a new request for the first of those accounts was still accepted, and an advisor who tried to approve the proposal was refused.

The proposal also told the approver something they need to know: one account has no policy yet, so the rule cannot apply to it until someone sets one. A check over an empty value does not fire, and it is better to learn that before the rule goes live.

The back office user approved it through the API. The publication recorded who proposed it, who approved it and in which role. This step is optional: a contract that names no approvers goes live when it is published.

Step 4: it applies from the next action

No deploy happened. That first account's next request:

HTTP 422 validation_violation
a validation rule refused the write (entity withdrawal_requests; rule recent_destination_change)

The three accounts accepted above were accepted again. Refusals from before and after a change cite different contract versions by their hash, in the chain of decisions.

Two more things changed with no new version of the contract, because they are data. The back office shortened the policy to 1 day and the same request was accepted. With the hold back at 7 days, an advisor tried to record the verification and was refused. The back office recorded it, and the request went through.

What this does not do