First controlled action¶
This tutorial runs entirely offline. It uses a local ledger stub so you can observe policy behavior without credentials or real money moving.
What this demonstrates¶
| Field | Value |
|---|---|
| Status | Supported core API |
| Execution mode | Offline simulation |
| Requirements | Python 3.11+, build-tested SDK 2.16.0 |
| External credentials | None |
| Limitation | The ledger is in memory; no payment provider is contacted |
Three actions enter the same gate:
- a $12.50 refund is allowed and reaches the ledger;
- a $4,000 refund is held and does not reach the ledger;
- an action tagged
prohibitedis denied and does not reach the ledger.
Save the example¶
Create controlled_refund.py:
import asyncio
from tulip.control import (
Action,
AdmissionError,
AuditTrail,
ControlPolicy,
admit,
)
policy = ControlPolicy(
require_verification_score=0.0,
max_blast_radius=1,
require_human_for=frozenset({"high_value"}),
deny_for=frozenset({"prohibited"}),
)
trail = AuditTrail()
paid = []
async def try_refund(customer, amount, *, entries=1, tags=frozenset()):
action = Action(
name="issue_refund",
asset=customer,
kind="payment",
environment="production",
blast_radius=entries,
tags=frozenset({"refund", *tags}),
)
async def pay():
paid.append((customer, amount))
return f"paid ${amount:,.2f}"
try:
result = await admit(action, pay, policy=policy, trail=trail)
print("ALLOW", result)
except AdmissionError as exc:
print(exc.decision.outcome.upper(), "not run")
async def main():
await try_refund("cust:small", 12.50)
await try_refund("cust:large", 4_000, entries=6, tags={"high_value"})
await try_refund("cust:blocked", 10, tags={"prohibited"})
print("paid:", paid)
print("outcomes:", [r.payload["outcome"] for r in trail.records()])
print("chain valid:", trail.verify())
asyncio.run(main())
Run it:
Expected output:
ALLOW paid $12.50
REQUIRE_HUMAN not run
DENY not run
paid: [('cust:small', 12.5)]
outcomes: ['allow', 'require_human', 'deny']
chain valid: True
What the result proves¶
For these three calls, admit() invoked pay() only after the configured
policy returned allow. It wrote each decision to the supplied in-memory
trail, and trail.verify() confirmed that trail's hash chain was intact.
It does not prove that every refund path in an application is gated. You
must route each consequential path through admit() or gate_tool(), classify
the action accurately, store approvals and audit records durably, and use a
payment-provider idempotency key for crash-safe external effects.
Next steps¶
- Control-layer architecture explains the enforcement boundary.
- Writing a policy that holds covers missing risk families and second paths.
- Payment refund example provides a more fully narrated runnable file.
- Idempotency explains retries and the external operation/recording crash window.