Skip to content

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 prohibited is 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:

python controlled_refund.py

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