Refund agent — an LLM decides, the language decides what it may do
The model proposes approve / deny / escalate as JSON; its output becomes a sum type (malformed → Escalate, never a crash). The hard rules are not the model's to bend: a refund above the order amount or after 30 days is refused by guards on the edge to `paid`; the daily payout cap is a memory invariant; a refund above 20,000 cents needs a human (`approve()`), which tests script with `mock approve`. Proven: paid is unreachable without decided; nothing after denied. Proven too: the cost bound, because every think() has a literal max_tokens and one provider round (max_rounds).
A Soma program from the services part of the verified corpus: 162 lines, cells Decision, RefundAgent, RefundTests. It passes soma check with soma 3.2.4.
How it is built
The cell RefundAgent declares 4 signals in its face: seed_order(order_id: String, amount: Int, days_since_delivery: Int) -> Int, decide(request_id: String, order_id: String, amount: Int, text: String) -> Map, status(request_id: String) -> String and paid_today() -> Int; keeps 2 memory slots: orders (Map<String, Map>, persistent) and paid_by_day (Map<String, Int>, persistent) under the invariant paid_by_day >= 0 && paid_by_day <= 100000. Its state machine request starts in received and allows 5 transitions: received -> decided, decided -> paid (guarded), decided -> denied, decided -> escalated and * -> failed. denied, escalated, failed and paid are terminal. Handlers: seed_order (line 49), lookup_order (line 53), paid_today (line 58), status (line 59) and decide (line 61). Private helpers: _pay and _parse.
The file also holds 1 test cell (RefundTests) with 19 assertions, run by soma test.
The source
soma.toml
[package]
name = "services-corpus"
version = "0.1.0"
description = "Whole verified services: HTTP + lifecycle + invariants, in one file each"
[agent]
mock = "echo"
# payments_approval.cell — the properties its README promises
[verify]
cells = ["Payments"]
eventually = ["paid", "rejected"]
[verify.before.paid]
requires = ["authorized"]
[verify.after.rejected]
never = ["paid", "authorized", "signed_off"]
[verify.after.paid]
never = ["rejected"]refund_agent.cell
// REFUND AGENT — an LLM decides, the language decides what it may do.
//
// The model proposes approve / deny / escalate as JSON; its output becomes a
// sum type (malformed → Escalate, never a crash). The hard rules are not the
// model's to bend: a refund above the order amount or after 30 days is
// refused by guards on the edge to `paid`; the daily payout cap is a memory
// invariant; a refund above 20,000 cents needs a human (`approve()`), which
// tests script with `mock approve`. Proven: paid is unreachable without
// decided; nothing after denied. Proven too: the cost bound, because every
// think() has a literal max_tokens and one provider round (max_rounds).
//
// soma check refund_agent.cell → cost: 'tokens' bound proven
// soma verify refund_agent.cell
// soma test refund_agent.cell → offline: think() and approve() are scripted
cell type Decision {
variants {
Approve
Deny { reason: String }
Escalate { reason: String }
}
}
cell agent RefundAgent {
face {
signal seed_order(order_id: String, amount: Int, days_since_delivery: Int) -> Int
signal decide(request_id: String, order_id: String, amount: Int, text: String) -> Map
signal status(request_id: String) -> String
signal paid_today() -> Int
tool lookup_order(order_id: String) -> Map "Order amount and days since delivery"
}
cost {
tokens: 300
}
memory {
orders: Map<String, Map> [persistent]
paid_by_day: Map<String, Int> [persistent]
invariant paid_by_day >= 0 && paid_by_day <= 100000 // daily cap, whatever the model says
}
state request {
initial: received
received -> decided
decided -> paid { guard { amount <= order.amount && order.days <= 30 } }
decided -> denied
decided -> escalated
* -> failed except [paid, denied, escalated]
}
on seed_order(order_id: String, amount: Int, days_since_delivery: Int) {
orders.set(order_id, map("amount", amount, "days", days_since_delivery))
return amount
}
on lookup_order(order_id: String) {
let o = orders.get(order_id)
if o == () { fail("not_found", "order {order_id}") }
return o
}
on paid_today() { return paid_by_day.get(today()) ?? 0 }
on status(request_id: String) { return get_status(request_id) }
on decide(request_id: String, order_id: String, amount: Int, text: String) {
let order = lookup_order(order_id)
set_budget(2000)
let r = try {
think(
"Refund request for order {order_id}: {text}. Amount asked: {amount}. Reply with JSON {\"decision\": \"approve\"|\"deny\"|\"escalate\", \"reason\": \"...\"}",
"You are a support agent. Be strict.",
map("max_tokens", 300, "max_rounds", 1)
)
}
if r.error != () {
transition(request_id, "failed")
return map("state", "failed", "reason", r.detail)
}
transition(request_id, "decided")
let decision = _parse(r.value)
return match decision {
Approve -> _pay(request_id, order_id, amount, order)
Deny { reason } -> {
transition(request_id, "denied")
map("state", "denied", "reason", reason)
}
Escalate { reason } -> {
transition(request_id, "escalated")
map("state", "escalated", "reason", reason)
}
}
}
// Approval says approve; the rules decide. The guard on decided -> paid
// reads `amount` and `order` from this handler; a refused guard is
// caught, rolled back, and the case is escalated instead.
on _pay(request_id: String, order_id: String, amount: Int, order: Map) {
if amount > 20000 && !approve("refund {amount} on {order_id}") {
transition(request_id, "escalated")
return map("state", "escalated", "reason", "human declined")
}
let p = try {
transition(request_id, "paid")
paid_by_day.set(today(), paid_today() + amount) // the daily cap lives here
}
if p.error != () {
transition(request_id, "escalated")
return map("state", "escalated", "reason", p.detail)
}
return map("state", "paid", "amount", amount)
}
on _parse(raw: String) {
let j = try { from_json(raw) }
if j.error != () || type_of(j.value) != "Map" { return Escalate { reason: "unreadable decision" } }
let d = j.value
return match d.decision ?? "" {
"approve" -> Approve
"deny" -> Deny { reason: d.reason ?? "" }
_ -> Escalate { reason: d.reason ?? "unknown decision" }
}
}
}
cell test RefundTests {
rules {
assert seed_order("o1", 5000, 3) == 5000
assert seed_order("o2", 50000, 3) == 50000
assert seed_order("o3", 5000, 45) == 5000
// the model approves, deny and escalate — scripted per test
mock think """{"decision": "approve", "reason": "ok"}"""
assert decide("r1", "o1", 4000, "broken").state == "paid"
assert status("r1") == "paid"
mock think """{"decision": "deny", "reason": "used"}"""
assert decide("r2", "o1", 4000, "meh").state == "denied"
mock think "garbage from the model"
assert decide("r3", "o1", 10, "?").state == "escalated"
// the model says approve for MORE than the order: the guard refuses, the case escalates
mock think """{"decision": "approve", "reason": "generous"}"""
assert decide("r4", "o1", 9000, "want more").state == "escalated"
assert status("r4") == "escalated"
assert paid_today() == 4000
// 30-day rule
mock think """{"decision": "approve"}"""
assert decide("r5", "o3", 100, "late").state == "escalated"
// human gate above 20,000: declined, then accepted
mock think """{"decision": "approve"}"""
mock approve false
assert decide("r6", "o2", 30000, "big").state == "escalated"
mock think """{"decision": "approve"}"""
mock approve true
assert decide("r7", "o2", 30000, "big").state == "paid"
assert paid_today() == 34000
// the daily cap: 34000 + 70000 > 100000 → escalated, cap intact
assert seed_order("o4", 100000, 1) == 100000
mock think """{"decision": "approve"}"""
mock approve true
assert decide("r8", "o4", 70000, "huge").state == "escalated"
assert paid_today() == 34000
// an LLM failure is a failed request, not a crash
mock think error "timeout"
assert decide("r9", "o1", 10, "x").state == "failed"
assert status("r9") == "failed"
}
}What the toolchain says
$ soma check refund_agent.cell
✓ cost: 'tokens' bound proven — peak 300 reply tokens (the max_tokens caps) ≤ declared 300 (prompts are not counted: cap them with set_budget)
✓ All checks passed (1 note above).$ soma test refund_agent.cell
test RefundTests ...
✓ assert seed_order("o1", 5000, 3) == 5000
✓ assert seed_order("o2", 50000, 3) == 50000
✓ assert seed_order("o3", 5000, 45) == 5000
✓ assert decide("r1", "o1", 4000, "broken").state == "paid"
✓ assert status("r1") == "paid"
✓ assert decide("r2", "o1", 4000, "meh").state == "denied"
✓ assert decide("r3", "o1", 10, "?").state == "escalated"
✓ assert decide("r4", "o1", 9000, "want more").state == "escalated"
✓ assert status("r4") == "escalated"
✓ assert paid_today() == 4000
✓ assert decide("r5", "o3", 100, "late").state == "escalated"
✓ assert decide("r6", "o2", 30000, "big").state == "escalated"
✓ assert decide("r7", "o2", 30000, "big").state == "paid"
✓ assert paid_today() == 34000
✓ assert seed_order("o4", 100000, 1) == 100000
✓ assert decide("r8", "o4", 70000, "huge").state == "escalated"
✓ assert paid_today() == 34000
✓ assert decide("r9", "o1", 10, "x").state == "failed"
✓ assert status("r9") == "failed"
19 tests: 19 passed, 0 failed$ soma verify refund_agent.cell
Verifying refund_agent.cell...
note: soma.toml [verify] cells names 'Payments', which this file does not define — its properties are not checked here
State machine 'request': 6 states, initial 'received'
States: [decided, denied, escalated, failed, paid, received]
✓ 6 states, 6 transitions
✓ all states reachable from 'received'
✓ terminal states: [paid, denied, escalated, failed]
✓ no deadlocks
✓ liveness: every state can eventually reach a terminal state
· guarded edges kept in the model (liveness and `eventually` above assume they can pass): decided -> paid
✓ wildcard transitions: * -> [failed]
✓ think-isolated: CTL safety properties hold regardless of LLM output (2 handlers, 7 literal transitions, 0 dynamic)
✓ termination: all 7 handlers structurally terminate
✓ refinement: handler `decide` ⟶ {failed [if r.error != ()], decided, denied [(match arm …)], escalated [(match arm …)]}
✓ refinement: handler `_pay` ⟶ {escalated [if amount > 20000 && if true { … } else { … }], paid [(match arm _)], escalated [if p.error != ()]}
· transition sources: `decide`, `_pay` call transition(id, target) without declaring the source — the runtime refuses a move the graph forbids, but the proof does not see where the handler starts; `transition(id, from, to)` names the edge (checked by `soma check`, refused at run time from any other state)
⚠ guard `amount <= order.amount && order.days <= 30` on decided -> paid: runtime-checked in `_pay` — put `require amount <= order.amount && order.days <= 30 else Tag` before the transition() (no require or if establishes the same comparison there)
✓ effects: handler `decide` calls think() at 2 sites (the cost line counts loop rounds) — can dispatch [lookup_order]
RefundAgent/invariants:
⚠ invariant paid_by_day >= 0 && paid_by_day <= 100000 — runtime-checked (computed values): _pay → paid_by_day because `amount` is a parameter (narrow it: `require (paid_today() + amount) >= 0 && (paid_today() + amount) <= 100000 else …`)
11 passed, 2 warnings, 0 failures
Temporal properties for 'request':
✓ deadlock_free — no deadlocks in any reachable state
soma.toml: 6 user-defined properties loaded — NONE applies to this file ([verify] cells = ["Payments"] names no cell of it)
Temporal: 1 passed, 0 failed
VERIFY OKBuiltins used
approve, fail, from_json, get_status, map, set_budget, think, today, transition, type_of