A refund gate that pays small refunds and holds big ones¶
A support agent talks to an upset customer and decides a refund is owed. It should be free to settle the small stuff on its own — a $12 shipping credit, a duplicate-charge reversal — without paging a human. But a $4,000 refund, or one that reverses a whole batch of charges, should stop and wait for a person.
The decision of whether the money moves does not live in the agent's prompt.
It lives in admit(), a gate that runs before the payout. The agent can be
confused, jailbroken, or wrong about the dollar amount; the refund still only
goes through if the ControlPolicy admits it. Each proposed refund becomes an
Action whose dollar amount drives two policy-visible attributes: how many
ledger entries it touches (blast_radius) and whether it crosses the
human-review line (a high_value tag). The policy auto-allows a
single-ledger-entry refund that carries no high_value tag and holds anything
bigger.
require_verification_score is set to 0 on purpose: a refund is a business
action, not a security finding — there is no threat to verify, so the gate
reasons purely over amount and scope. Everything that happens — the paid refund
and the held one — lands on a tamper-evident AuditTrail, and
trail.verify() confirms the SHA-256 chain was not altered after the fact.
In this run the small refund (one ledger entry, $12.50) is paid automatically,
while the large one ($4,000 reversing a six-charge subscription batch) trips
both rules — the blast radius exceeds max_blast_radius and the
high_value tag is in require_human_for — so it raises an
AdmissionError(require_human), the payout never runs, and the hold is queued
for a human approver. Both decisions are recorded on the trail.
Runs offline — no network, no credentials; the payout is a local ledger stub.
Run it: python examples/notebook_83_payment_refund_gate.py
Source¶
#!/usr/bin/env python3
# Copyright 2026 Tulip Labs
# SPDX-License-Identifier: Apache-2.0
"""Notebook 83: A refund gate that pays out small refunds and holds big ones.
A support agent talks to an upset customer and decides a refund is owed. The
agent should be free to settle the small stuff on its own — a $12 shipping
credit, a duplicate-charge reversal — without paging a human. But a $4,000
refund, or one that reverses a whole batch of charges, should stop and wait
for a person.
The decision of *whether the money moves* does not live in the agent's prompt.
It lives in :func:`admit`, a gate that runs before the payout. The agent can be
confused, jailbroken, or wrong about the dollar amount; the refund still only
goes through if the :class:`ControlPolicy` admits it. Everything that happens —
the paid refund and the held one — lands on a tamper-evident
:class:`AuditTrail`.
Support agent proposes a refund
│
▼
Build an Action (amount → blast_radius, large → "high_value" tag)
│
▼
admit(action, pay_out, policy, trail)
│
├─ small refund ── policy allows ──> pay_out() runs, money moves
│
└─ large refund ── blast_radius over the cap, or "high_value" tag ──>
AdmissionError(require_human); pay_out() never runs,
the hold is queued for a human approver
│
▼
AuditTrail records every decision — paid or held
The policy here is deliberately small: auto-allow a single-ledger-entry refund
that carries no ``high_value`` tag; hold anything bigger. ``require_verification_score``
is set to 0 because a refund is a business action, not a security finding — there
is no threat to verify, so the gate reasons purely over amount and scope.
Run it (fully offline — no network, no credentials; the payout is a local stub):
python examples/notebook_83_payment_refund_gate.py
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from tulip.control import (
Action,
AdmissionError,
AuditTrail,
ControlPolicy,
admit,
)
# The "side effect" the agent wants to take. Offline, it just mutates a local
# ledger — but this is the one place money would actually move in production.
@dataclass
class Ledger:
"""A stand-in payment ledger. In production this would call the PSP."""
paid: list[tuple[str, float]] = field(default_factory=list)
held: list[tuple[str, float]] = field(default_factory=list)
def pay_out(self, customer: str, amount: float) -> str:
"""Actually move the money. Only ever reached after admit() allows it."""
self.paid.append((customer, amount))
return f"refunded ${amount:,.2f} to {customer}"
# How a proposed refund becomes an Action the policy can reason over. The dollar
# amount drives two policy-visible attributes: how many ledger entries the refund
# touches (blast_radius) and whether it crosses the human-review line (a tag).
LARGE_REFUND_THRESHOLD = 200.0
def refund_action(customer: str, amount: float, ledger_entries: int, reason: str) -> Action:
tags = {"refund"}
if amount >= LARGE_REFUND_THRESHOLD:
tags.add("high_value")
return Action(
name="issue_refund",
asset=customer,
blast_radius=ledger_entries,
environment="production",
kind="payment",
tags=frozenset(tags),
)
async def propose_refund(
ledger: Ledger,
policy: ControlPolicy,
trail: AuditTrail,
*,
customer: str,
amount: float,
ledger_entries: int,
reason: str,
) -> None:
"""Run one refund through the gate and narrate what happened."""
action = refund_action(customer, amount, ledger_entries, reason)
print(f"\n--- Support agent proposes: refund ${amount:,.2f} to {customer} ---")
print(f" reason: {reason}")
print(
f" scope: {ledger_entries} ledger entr{'y' if ledger_entries == 1 else 'ies'}, "
f"tags={sorted(action.tags)}"
)
try:
result = await admit(
action,
lambda: _pay_out(ledger, customer, amount),
policy=policy,
trail=trail,
)
print(f" PAID {result}")
except AdmissionError as exc:
ledger.held.append((customer, amount))
print(f" HELD not admitted ({exc.decision.outcome}) — queued for a human")
for check in exc.decision.checks:
print(f" · {check}")
async def _pay_out(ledger: Ledger, customer: str, amount: float) -> str:
# admit() awaits this; keep it async to match the perform() contract.
return ledger.pay_out(customer, amount)
async def main() -> None:
print("Notebook 83: A refund gate that pays out small refunds and holds big ones")
print("=" * 60)
# The CISO/finance knobs. require_verification_score=0 because a refund is a
# business action, not a security finding — there is nothing to "verify".
# max_blast_radius=1: a refund may touch one ledger entry to auto-pay.
# require_human_for={"high_value"}: any refund tagged high_value waits for a person.
policy = ControlPolicy(
require_verification_score=0.0,
max_blast_radius=1,
require_human_for=frozenset({"high_value"}),
)
trail = AuditTrail()
ledger = Ledger()
# 1) Small refund: one ledger entry, well under the threshold -> auto-paid.
await propose_refund(
ledger,
policy,
trail,
customer="cust:4821",
amount=12.50,
ledger_entries=1,
reason="duplicate shipping charge on order #A-9920",
)
# 2) Large refund: high dollar value (so it's tagged high_value) AND it would
# reverse a 6-charge subscription batch (blast_radius over the cap). Either
# one alone would hold it; here both fire, so the held decision lists both.
await propose_refund(
ledger,
policy,
trail,
customer="cust:7763",
amount=4_000.00,
ledger_entries=6,
reason="full-year subscription reversal after billing dispute",
)
# The narrative outcome.
print("\nLedger:")
print("-" * 60)
print(f" paid: {ledger.paid or '(none)'}")
print(f" held: {ledger.held or '(none)'}")
print("\nAudit trail:")
print("-" * 60)
for rec in trail.records():
print(
f" #{rec.seq} {rec.event_type}: {rec.payload['action']} "
f"{rec.payload['asset']} -> {rec.payload['outcome']}"
)
print(f" chain intact (tamper-evident): {trail.verify()}")
# Assert the gate did exactly what we claimed: the small refund moved money,
# the large one did not, and BOTH decisions were recorded on the trail.
assert ledger.paid == [("cust:4821", 12.50)], ledger.paid
assert ledger.held == [("cust:7763", 4_000.00)], ledger.held
assert len(trail) == 2, len(trail)
outcomes = [rec.payload["outcome"] for rec in trail.records()]
assert outcomes == ["allow", "require_human"], outcomes
assert trail.verify()
print("\nOK: small refund paid, large refund held, both on the audit trail.")
if __name__ == "__main__":
asyncio.run(main())