Decorators & extractors
This page is the deep-dive reference for the SDK's runtime-API surface — every decorator, impact extractor, and context manager that affects how a function call enters the gate. The top-level symbol table is in SDK API; this page explains the contracts each symbol establishes with the gate.
If you only want the "which one do I use?" answer, jump to When to use what. If you want the full contract for a single symbol, use the section headings below.
What's in scope
| Symbol | Type | Surface |
|---|---|---|
@protect |
decorator | eager (from nullrun import protect) |
@sensitive |
decorator (bare + factory) | lazy (from nullrun import sensitive) |
@guarded |
decorator | eager (via __all__) |
money_outflow(...) |
extractor factory | lazy (from nullrun import money_outflow) |
tool_params(...) |
extractor factory | lazy (from nullrun import tool_params) |
with workflow(...) |
context manager | lazy |
with span(...) |
context manager | lazy |
with agent(...) |
context manager | lazy |
with attempt(...) |
context manager | lazy |
with chain(...) |
context manager | lazy |
set_call_context(...) |
imperative setter | lazy |
Everything in this table participates in the gate decision for
at least one code path. Setters that only enrich observability
(set_trace_id, set_operation_id, etc.) are not covered here —
they are internal hooks the runtime drives from inside @protect.
@protect — the gate decorator
Parameters: none. @protect accepts only a callable (or None
when written with empty parens — the standard
@decorator ↔ @decorator() shape).
@nullrun.protect
def my_agent(prompt: str) -> str:
return call_llm(prompt)
@nullrun.protect
async def my_async_agent(prompt: str) -> str:
return await call_llm_async(prompt)
@nullrun.protect() # also valid — `fn=None` returns the decorator itself
def f(): ...
What @protect does on every call
A single @protect call runs four pre-execution gates in
strict order (ADR-008 Rule 4). The wrapper is shared between the
sync and async paths via a _protect_body context manager; only
the kill/pause signal translation differs.
| # | Gate | Failure mode | Sync behaviour | Async behaviour |
|---|---|---|---|---|
| 1 | check_control_plane(workflow_id) — KILL/PAUSE from the dashboard |
fail-CLOSED (kill is terminal) | Raise NullRunBlockedException(NR-W002) / (NR-W003) |
Re-raise the underlying NullRunWorkflowKilledError unchanged |
| 2 | check_workflow_budget() — /gate pre-flight reservation |
fail-OPEN on transport error (a transient backend outage must not freeze the user's agent) | NullRunBudgetError(NR-B004) on real block; transport error logs and proceeds |
identical |
| 3 | _emit_span_start(...) — observability span_start event |
never blocks — exceptions swallowed at DEBUG | identical | identical |
| 4 | _enforce_sensitive_tool(...) — /execute per-tool policy if fn.__name__ is in the sensitive set |
fail-CLOSED on transport error (a denied charge_card that runs when the policy engine is down is worse than a denied charge_card during an outage). Opt out via NULLRUN_SENSITIVE_FAIL_OPEN=1. |
NullRunBlockedException on real block; on NullRunTransportError re-raises with source-specific error_code (NR-B001/NR-B002/NR-A003/NR-B005) |
identical |
After the body completes, @protect calls
track_tool(fn.__name__, metadata={"arguments": _safe_kwargs(kwargs)})
to emit a tool-call span event tagged with the masked arguments.
Sensitive kwargs (PANs, tokens, etc., per SENSITIVE_ARG_KEYS)
are replaced with "***" before truncation so a long URL
never escapes the redaction window.
If any gate raises and the function body never ran, the wrapper
calls _safe_cancel_active_execution(reason="tool_exception") —
this hits POST /cancel to close the open Redis reservation
that /gate minted, so the budget doesn't leak via TTL expiry.
Sync vs async: the kill-signal divergence
The sync wrapper passes unify_block=True so a kill arriving
during @protect's own scaffolding is rewrapped into a single
NullRunBlockedException the user can catch uniformly. The async
wrapper passes unify_block=False — async frameworks
(asyncio.CancelledError, signal handlers) rely on the original
typed exception to interrupt cleanly. Re-raising
NullRunWorkflowKilledError as-is is required, not a bug.
Span hierarchy (built automatically)
_next_span() reads get_current_span(). If a parent span is
already active (outer @protect, with workflow, with span),
the new span becomes a child. Otherwise a fresh root is opened.
@nullrun.protect
def orchestrator(q):
return researcher(q) # child span
@nullrun.protect
def researcher(q):
return get_current_span() # parent.span_id == its parent_span_id
The dashboard reconstructs the whole tree from the
parent_span_id chain emitted in span_start events. You do not
need to pass span context through arguments.
When to use
Always on any function that calls an LLM, makes a tool call,
or spends money. @protect is the gate. The workflow is derived
from the API key on the backend; fn.__name__ becomes the
tool_name for the policy engine.
@sensitive — the per-tool policy marker
Two forms — bare and factory.
The bare form auto-attaches a ToolParamsExtractor(include_all=True)
inside _do_sensitive_register(). The kwargs of every call are
shipped on the wire so the gate can match them against
ToolParameters Approval Rules — see
Human approval → Typed predicates.
@nullrun.sensitive(impact=money_outflow(argument="amount_cents"))
@nullrun.protect
def refund_customer(amount_cents: int, customer_id: str): ...
The factory form attaches a typed impact extractor to the
function. The wrapper reads it from the _nullrun_extractor
attribute and forwards the typed BusinessImpact + the SHA-256
action_digest to /execute. The gate's post-approval re-check
refuses the call if the live payload drifts from the approved
digest.
The impact= parameter
Accepts one of two extractor objects.
money_outflow(...) — typed money impact
| Parameter | Type | Default | Notes |
|---|---|---|---|
argument |
str |
required | Name of the parameter to extract the amount from. Positional or keyword — inspect.signature(...).bind(...) makes them equivalent. |
currency |
str |
"USD" |
ISO-4217 3-letter uppercase. Whitelist: USD EUR GBP CHF CAD AUD JPY KWD BHD OMR. Anything else raises InvalidCurrencyError at decoration time — fail-CLOSED. |
units |
str |
"minor" |
"minor" (the bound argument is already in minor units — int is the canonical type; Decimal accepted if integer-valued). "major" (the bound argument is a Decimal in major units — the SDK converts via Decimal * 10**N where N = currency_minor_digits(currency)). The discriminator is explicit so a refactor of the function signature from int to Decimal does not silently flip the meaning. |
extractor_id |
str |
"nullrun.money.path" |
Self-reported SDK provenance. Advisory only; the trust boundary is the digest round-trip. |
extractor_version |
str |
"1" |
Self-reported version. |
enforce_business_cap |
bool |
True |
Per-currency cap (default $1,000,000 USD per call). Above the cap the extractor raises InvalidMoneyAmountError(reason="excessive") so the call goes through the explicit human-approval path. Set False for batch-settlement tools that already have an approval flow. |
What the extractor rejects outright:
| Input | Why |
|---|---|
bool |
bool is a subclass of int in Python — without the check, refund(amount=True) would silently treat True as 1 cent. |
float |
IEEE-754 surprises are the entire reason Decimal exists. Pass Decimal for major units, int for minor. |
| Negative amount | A negative outflow would silently fall through every op=gt predicate (-5000 > 5000 is always False). |
Decimal("50.005") for USD |
More fractional digits than the currency supports. Truncate explicitly with value.quantize(Decimal("1E-2")) to opt in to rounding; the SDK never rounds silently. |
Amount above 2**63 - 1 |
Wire-format i64 upper bound — checked after conversion. |
| Amount above per-currency cap | InvalidMoneyAmountError(reason="excessive") unless enforce_business_cap=False. |
The result is BusinessImpact(impact=MoneyImpact(...)) →
compute_action_digest() → 64 lowercase hex characters. The
digest MUST match the backend's calculation byte-for-byte; a
mismatch is a 403 DIGEST_MISMATCH on the post-approval re-check.
tool_params(...) — free-form argument bag
| Parameter | Type | Default | Notes |
|---|---|---|---|
param_extractors |
dict[str, str] \| None |
None |
Explicit {rule_param: arg_name} map. When set, only the listed args are captured under rule_param keys; everything else is dropped. Use this when the rule name diverges from the function arg name (e.g. {"user_id": "uid"}). include_all is ignored when this is set. |
include_all |
bool |
True |
Capture every kwarg verbatim. Set False (with param_extractors=None) for tools whose every kwarg is a secret the operator must never see. |
The two modes are mutually exclusive — passing both raises
ValueError at decoration time. The three effective extraction
modes (priority order):
param_extractorsset → only those args underrule_paramkeysinclude_all=True(default) → every kwarg as-is- neither → empty
params(rare; tools that take no args but should still be eligible forkind="tool_call"approval rules)
What the extractor filters out:
***masked sentinels (PII-masked values that would never match a real rule)floatvalues (JSON round-trip is not lossless for IEEE-754)- unsupported types (
set, custom objects)
The wire shape is BusinessImpact(impact=ToolCallParams(...)) and
shares the same action_digest contract as the money variant.
Order of application
# Recommended: @sensitive outside (top), @protect inside (bottom)
@nullrun.sensitive(impact=money_outflow(argument="amount_cents"))
@nullrun.protect
def charge(amount_cents: int): ...
# Also works: @protect outside. Same observable behaviour.
@nullrun.protect
@nullrun.sensitive(impact=money_outflow(argument="amount_cents"))
def charge(amount_cents: int): ...
The recommended form is @sensitive outside so the registration
in runtime.add_sensitive_tool(fn.__name__) happens before the
@protect wrapper is built. functools.wraps makes both orders
work either way.
When to use
| Tool category | Recommendation |
|---|---|
Read-only tools (get_weather, list_files) |
No @sensitive — @protect alone covers budget + span tracking. The gate's per-tool policy runs on /execute only for marked tools. |
Side-effect tools with bounded blast radius (send_email, revoke_access, delete_user) |
@sensitive (bare) — the kwargs become approval-rule predicates. |
Money-moving tools (refund, charge_card, transfer) |
@sensitive(impact=money_outflow(...)) — typed impact + action_digest for tamper-proof approval flow. |
| Tools where rule names ≠ arg names | @sensitive(impact=tool_params({"rule_param": "arg_name"})) |
| Tools whose every kwarg is a secret | @sensitive(impact=tool_params(include_all=False)) to ship an empty params bag |
Without @sensitive (or an explicit
runtime.add_sensitive_tool(fn.__name__)), the _enforce_sensitive_tool
gate is a no-op — the function body runs immediately after
/gate. This violates the fail-CLOSED contract for any
irreversible action.
@guarded — friendly-exit wrapper
Parameters: none. Accepts only a callable.
@nullrun.guarded
@nullrun.protect
def my_agent(prompt: str) -> str:
return call_llm(prompt)
What @guarded does
Any NullRunError raised inside the wrapped function is caught,
rendered as the catalog user-message via format_user_message(exc),
printed to stderr, and the process exits with code 1.
Exceptions that propagate unchanged:
NullRunWorkflowKilledError(kill signal) — kill must reach the top of the agent loop, not be swallowed into a graceful exit. Re-raised explicitly inside theexcept NullRunErrorbranch.KeyboardInterrupt/SystemExit— same reason; they don't reach theexcept NullRunErrorbranch anyway.- Any non-NullRun exception — the user's own bugs are not handled here; let them propagate for an honest traceback.
Order of application
@guarded is always outside @protect:
# CORRECT
@nullrun.guarded
@nullrun.protect
def my_agent(prompt): ...
# WRONG — @guarded below @protect does not protect the body
@nullrun.protect
@nullrun.guarded
def my_agent(prompt): ...
When to use
For top-level entry points in scripts and CLIs: instead of a
raw traceback on NullRunAuthenticationError(NR-C001), the
operator sees the catalog wording and the process exits cleanly.
In libraries and long-running services, prefer
try/except NullRunError — @guarded exits the process, which
isn't appropriate there.
The context-manager form with nullrun.handle(): is equivalent
for region-of-code scopes:
import nullrun
nullrun.init(api_key="nr_live_...")
with nullrun.handle():
run_my_agent("hello")
# ↑ if run_my_agent raised NullRunError, the catalog user-message
# is printed and the script exits 1.
Context managers
| Context manager | Parameters | What it sets |
|---|---|---|
with workflow(name=None) |
name: str \| None |
Root scope: pushes workflow_id + trace_id + span_id (root SpanContext). All @protect and track_* calls inside auto-tag events with this workflow_id. |
with span(name=None) |
name: str \| None |
Child span derived from the active parent SpanContext. No-op if no parent is active (bare with span(...) outside any workflow/protect block keeps the legacy fallback). |
with agent(name=None) |
name: str \| None |
Sets agent_id for per-agent cost attribution. |
with attempt(attempt_index) |
attempt_index: int |
Sets attempt_index for retry correlation. |
with chain(chain_id, op="start") |
chain_id: str (UUID v4), op: str |
Soft-mode budget gate. Overdrafts are allowed only when an active chain is registered against the org. op is "start" / "continue" / "end" / "auto" (default). |
workflow() and the policy binding
The workflow_id is the join key that binds a run to a
dashboard-defined workflow (with its budget cap and per-workflow
policies). The name you pass should match the workflow your API
key is bound to — otherwise the gate falls back to an ad-hoc
workflow_id with no budget policy attached. For a one-shot test
script, None is fine: the SDK mints a UUID and the run lives as
an unattached workflow.
chain() and the UUID v4 validation
import uuid
import nullrun
chain_id = str(uuid.uuid4()) # MUST be a UUID v4 string
with nullrun.chain(chain_id, op="start"):
my_long_running_agent() # every /gate call extends the chain TTL
The chain_id is validated client-side per CLAUDE.md §6: the
backend's chain race guard (HGET chain_key 'org_id') does not
validate UUID format — non-UUID or non-v4 chain_ids silently
auto-register as new ACTIVE chains, which is both a typo trap
and a predictable-UUID risk. The SDK raises ValueError at
with chain(...) entry on malformed input.
chain is the soft-mode companion to a Hard budget: the budget
allows a bounded overrun only when an active chain is present.
Long-running streams should also call
runtime.ping_chain(chain_id, interval=30.0) to extend the TTL
faster than the natural /check cadence.
Nested with span(...)
with nullrun.workflow("my-agent"):
with nullrun.span("plan-generation"): # child of workflow root
plan = my_agent(user_input)
with nullrun.span("tool-call"): # grandchild
tool.invoke(plan)
The dashboard renders the tree from the parent_span_id chain
emitted in span_start events — there is no manual context
threading.
set_call_context() — per-call data for /gate
nullrun.set_call_context(
model="claude-sonnet-4-6", # LLM model name; backend looks up per-model rate
tools=["send_email", "refund_customer"], # matched against workflow's blocked_tools
)
| Parameter | Type | Default | Effect when unset |
|---|---|---|---|
model |
str \| None |
None (no change) |
Backend receives the literal "budget-precheck" and falls back to the default pricing rate. Per-model budget tiers cannot fire. |
tools |
list[str] \| tuple[str, ...] \| None |
None (no change) |
Backend skips ToolBlock enforcement on /gate. Pass [] to clear (different from None, which leaves the previous value). |
Call this inside a with workflow(...) block, before the
@protect call. The values are forwarded on the /gate request
so the backend can:
- compute
projected_costfrom the real model rate (not the default fallback); - evaluate the workflow's
blocked_toolsaggregate against the call's intended tool list (otherwiseToolBlockonly runs on/track).
Both fields default to None / empty; users opt in by calling
set_call_context explicitly. The @protect wrapper itself
populates the tools field with (fn.__name__,) if the user
didn't — a defensive default so a bare @protect still triggers
the tool-block check for that single function name.
When to use what
| Task | API |
|---|---|
| Wrap a function that calls an LLM or tool | @nullrun.protect (no parameters) |
| Mark an irreversible tool for per-tool policy | @nullrun.sensitive (bare) |
| Mark a money-moving tool for typed approval | @nullrun.sensitive(impact=money_outflow(argument="amount_cents", currency="USD")) |
| Mark a tool where rule names ≠ arg names | @nullrun.sensitive(impact=tool_params({"user_id": "uid"})) |
| Mark a tool where every kwarg is a secret | @nullrun.sensitive(impact=tool_params(include_all=False)) |
| Top-level script entry (friendly exit) | @nullrun.guarded or with nullrun.handle(): |
| Multi-step agent run (cost + trace per workflow) | with nullrun.workflow("agent-name"): ... |
Per-call model name and tools for /gate |
nullrun.set_call_context(model=..., tools=[...]) inside with workflow |
| Soft-mode budget (controlled overdrafts) | with nullrun.chain(uuid.uuid4(), op="start"): ... |
| LangGraph auto-tracking | from nullrun.toolbox.langgraph import wrapper; graph = wrapper(graph) |
| Manual LLM tracking (custom client) | nullrun.track_llm(input_tokens=..., output_tokens=..., model=...) |
| Manual tool-call tracking | nullrun.track_tool(tool_name=..., duration_ms=..., metadata=...) |
| Custom business event | nullrun.track_event("agent.milestone", step=..., elapsed_secs=...) |
| Audit log read | runtime.audit.list(AuditQuery(event_type=..., since=..., limit=...)) |
| Global error hook (Sentry, OTel) | nullrun.on_error(my_handler) — returns an idempotent unregister callable |
| Snapshot runtime state | nullrun.status() — frozen NullRunStatus dataclass |
| Graceful exit (WS close, flush events) | nullrun.shutdown() or nullrun.shutdown(flush=False) in tests |
Order of application — cheat sheet
# ─── Sensitive money tool ───
@nullrun.sensitive(impact=money_outflow(argument="amount_cents", currency="USD"))
@nullrun.protect
def refund(amount_cents: int): ...
# ─── Plain trackable function ───
@nullrun.protect
def my_agent(prompt): ...
# ─── Nested @protect builds the span tree automatically ───
@nullrun.protect
def orchestrator(q):
return researcher(q) # child span
@nullrun.protect
def researcher(q):
return get_current_span() # parent's span_id == parent_span_id
# ─── Top-level script entry with friendly exit ───
@nullrun.guarded
@nullrun.protect
def main(prompt): ...
# ─── Full layering: chain → workflow → call context → @protect ───
import uuid
import nullrun
nullrun.init(api_key="nr_live_...")
chain_id = str(uuid.uuid4())
with nullrun.chain(chain_id, op="start"): # soft-mode budget
with nullrun.workflow("customer-support"): # root trace
with nullrun.span("plan-generation"): # child span
nullrun.set_call_context( # model + tools for /gate
model="claude-sonnet-4-6",
tools=["send_email", "refund_customer"],
)
plan = my_agent(user_input) # @protect inside
Anti-patterns
Don't put @protect outside @sensitive
Either order works, but the recommended convention is
@sensitive outside so registration in
runtime.add_sensitive_tool happens before the @protect
wrapper is built. Both produce identical observable behaviour
today; future shape changes may not.
Don't put @guarded below @protect
@guarded only catches errors raised inside the function it
decorates. A bare @guarded underneath @protect is a no-op
for gate-time errors — the exception is raised by the
@protect wrapper, never reaches the user's function, and
bubbles past @guarded unhandled.
Don't pass cost_cents to track_llm
The SDK strips it before sending. Cost is recomputed on the
backend from input_tokens + output_tokens + org pricing policy.
tokens is the only valid unit on the wire.
Don't use money_outflow(argument="amount_cents") on a float parameter
float is rejected outright. Decimal for major units, int
for minor units — the unit discriminator (units="minor" vs
units="major") is explicit and does not flip when you
change the type annotation.
Don't use money_outflow(units="major") on an int parameter
int is rejected. int is only valid under units="minor".
The explicit unit discriminator is the same review that
rejected implicit-from-type — a future refactor of the
signature (int → Decimal) must not silently flip the
meaning from cents to dollars.
Don't call set_chain_id("my-custom-id")
chain_id MUST be a UUID v4 string per CLAUDE.md §6. The
backend's race guard does not validate format — non-v4 ids
silently auto-register as new ACTIVE chains. Use
with nullrun.chain(uuid.uuid4(), op="start") to let the SDK
validate.
Don't put @sensitive outside any with workflow(...) scope in production
Bare @sensitive outside a workflow scope carries the sentinel
__nullrun_unknown__ as the displayed workflow_id. The
dashboard renders this as "unknown workflow" — operators can't
attribute the call to a real policy.
Don't use set_call_context(model="...") to override cost
model only changes which rate the backend uses to compute
projected_cost. The actual cost comes from real token counts
on /track. Faking model to lower the projected cost doesn't
reduce the actual charge.
See also
- SDK API — top-level symbol table, exceptions, manual tracking, transport hooks
- Sensitive tools (concept) —
@sensitivevsToolBlock, why the SDK does not ship a built-in sensitive list - Human approval — typed predicates
(
money_amount,tool_parameters) andaction_digest - Workflows — dashboard-side view of a workflow (budget cap, API keys, executions, traces)
- Custom tracking — when to use
track_llm/track_tool/track_eventinstead of auto-instrumentation - Use with LangGraph —
wrapper()helper and the LangGraph extra