Prefect
Decorate Prefect tasks so the kernel evaluates each one before its body runs and its decision lands on the flow-run timeline.
On this page
Decorate a Prefect task with @chio_task, and the kernel evaluates it before its body runs. @chio_flow sets the scope the flow's tasks share, and each decision appears in Prefect as an event on the task run it governed.
The Chio sidecar runs on each Prefect worker's host or pod, where the task bodies execute, so the worker is the enforcement point. The decorators call the sidecar over HTTP. Prefect's API server and events backend stay as they are, and the receipt log stays outside flow-run history.
Startr1search_documentsr2analyze_resultsr3search_documents
Each decorated task is admitted before its body runs
@chio_task evaluates the dispatch against the flow's scope before the task body runs. Only an allow runs the body; Prefect keeps its own retries, timeouts, and task runners. The mediated call.
Prefect scopes access per deployment, and its run history records what happened. With the decorators, access is per tool and scope, time-bounded, and revocable: a revoked capability refuses the task before its side effects, and the receipt records what was allowed and why. Every option you pass to @task or @flow passes through verbatim, including retries, timeouts, task runners, tags, and custom result storage.
1. Install the adapter on the Prefect worker
chio-prefect targets Prefect 3 and pulls in the Chio Python SDK. One sidecar per worker serves every flow the worker runs; if the host already runs a sidecar for another SDK, reuse it.
Before you start
Python 3.11+ and pip in a macOS or Linux shell. Run the commands from your project directory.
Create a Python environment
Terminal · your projectpython3 -m venv .venv source .venv/bin/activateInstall the SDK and the adapter
Terminal · your project, with .venv activepython -m pip install chio-sdk chio-prefectCheck the import
Terminal · the same Python environmentpython -c 'from chio_prefect import chio_flow, chio_task; print("Prefect adapter ready")'Expected checkpointPrefect adapter ready
The adapter imports. A task decorated with chio_task is evaluated before its body runs, and a refusal fails the task run.
You should see Prefect adapter ready. The decorators call the sidecar at http://127.0.0.1:9090 by default; pass sidecar_url= on any decorator to change it.
2. Decorate the flow and the tasks it runs
Your application supplies external_search, analyzer, and the client authority. Set CHIO_PREFECT_CAPABILITY_ID to the grant issued for both web tools.
import os
from application import analyzer, authority, external_search
from chio_sdk import ChioScope, Operation, ToolGrant
from chio_prefect import chio_flow, chio_task
PIPELINE_SCOPE = ChioScope(
grants=[
ToolGrant(
server_id="web",
tool_name="search_prices",
operations=[Operation.INVOKE],
),
ToolGrant(
server_id="web",
tool_name="summarize_prices",
operations=[Operation.INVOKE],
),
]
)
@chio_task(tool_server="web")
def search_prices(sku: str) -> list[dict]:
return external_search.run(sku)
@chio_task(tool_server="web")
def summarize_prices(listings: list[dict]) -> dict:
return analyzer.run(listings)
@chio_flow(
scope=PIPELINE_SCOPE,
capability_id=os.environ["CHIO_PREFECT_CAPABILITY_ID"],
tool_server="web",
chio_client=authority,
)
def competitor_pricing(sku: str) -> dict:
listings = search_prices(sku)
return summarize_prices(listings)
competitor_pricing("sku-204")Each decorated task is evaluated under the flow's grant through the client you pass as chio_client, and only an allow runs its body. Without a chio_client, each decorated call opens a ChioClient and closes it when the call ends, so inject one client when a worker runs many tasks at once.
The decorator admits the task as a whole. Code inside the body runs in the worker's process, so for native kernel execution of each tool, call governed tools from hosted process workers.
3. Keep each task inside the flow's scope
A declared task scope must fit inside the enclosing flow scope. A flow scoped to search and analysis rejects a task that declares a file-write scope when that task is called.
# Allowed: task scope is a subset of flow scope
@chio_task(
scope=ChioScope(grants=[
ToolGrant(server_id="web", tool_name="search_prices",
operations=[Operation.INVOKE]),
]),
tool_server="web",
)
def search_prices(sku: str) -> list[dict]: ...
# Denied at call time: task scope escapes the flow envelope
@chio_task(
scope=ChioScope(grants=[
ToolGrant(server_id="workspace", tool_name="write_file",
operations=[Operation.INVOKE]),
]),
tool_server="workspace",
)
def write_file(path: str, body: bytes) -> None: ...Tasks that omit scope inherit the flow's. A task called outside any @chio_flow declares its own capability_id; without one, its first call raises ChioPrefectConfigError in your process before any sidecar call. Decoration always succeeds.
The flow scope is the one a deployment owner approves; a task scope records which grant the task needs. Keep task scopes narrower than the flow scope so the subset check means something. Authorization itself comes from the grant and the client.
4. Catch a refusal as a failed task run
A deny raises PermissionError from the task wrapper, and Prefect marks the task run failed. The decorator attaches the structured verdict to the exception as PermissionError.chio_error, a ChioPrefectError.
import logging
from chio_prefect import ChioPrefectError
log = logging.getLogger(__name__)
try:
competitor_pricing("sku-301")
except PermissionError as e:
err: ChioPrefectError | None = getattr(e, "chio_error", None)
if err is not None:
log.warning(
"denied by Chio: receipt=%s guard=%s reason=%s",
err.receipt_id, err.guard, err.reason,
)
else:
raiseYou should see the receipt id, the guard, and the reason in the warning. Catch PermissionError and read chio_error: it is populated on both refusal paths, so policy failures stay distinct from business failures. Prefect owns retries. Retry a refusal only when its guard reads state that can change between attempts, with a retry condition.
5. Find each decision on its task run
The decorator emits one event for each returned decision, before the task body runs. The event links its receipt to the task-run timeline.
| Event | Emitted | Payload |
|---|---|---|
chio.receipt.allow | On allow, before the body | Receipt id, capability id, tool server, tool name, timestamp |
chio.receipt.deny | On deny, before the body | Receipt id, guard, reason, capability id, tool server, tool name |
# Event shape, with IDs abbreviated
{
"event": "chio.receipt.allow",
"resource": {
"prefect.resource.id": "chio.receipt.9f2c0a1d...c41e",
"prefect.resource.role": "chio-receipt",
"chio.capability_id": "cap-competitor-pricing",
"chio.tool_server": "web",
"chio.tool_name": "search_prices",
},
"payload": {
"receipt_id": "9f2c0a1d...c41e",
"verdict": "allow",
"capability_id": "cap-competitor-pricing",
"tool_server": "web",
"tool_name": "search_prices",
"task_name": "search_prices",
"timestamp": 1776631242,
},
"related": [
{"prefect.resource.id": "prefect.flow-run.<flow-run-id>",
"prefect.resource.role": "flow-run"},
{"prefect.resource.id": "prefect.task-run.<task-run-id>",
"prefect.resource.role": "task-run",
"prefect.resource.name": "search_prices"},
],
}You should see the event on the task run's row in the Prefect UI. Its subject is the receipt, and the flow-run and task-run ids ride in the related list, so you can pivot from a receipt back to the exact flow run.
When the events backend is unavailable, the decorator logs the event name, receipt id, tool, and verdict at INFO and moves on; it keeps no durable queue and no copy of the receipt. Retain receipts from the kernel's store, independently of Prefect event delivery.
Adapter reference
Try redaction_policy, retry_condition_fn, allow_all, related, or asyncio.
Event resources
The decorator passes resource, payload, and related to prefect.events.emit_event. The task and flow ids sit in related beside the receipt subject.
def _receipt_resource(receipt: ChioReceipt) -> dict[str, str]:
"""Build the :class:`prefect.events.Resource` dict for a receipt."""
return {
"prefect.resource.id": f"chio.receipt.{receipt.id}",
"prefect.resource.role": "chio-receipt",
"chio.capability_id": receipt.capability_id or "",
"chio.tool_server": receipt.tool_server or "",
"chio.tool_name": receipt.tool_name or "",
}
def _task_related(
*,
task_name: str,
flow_run_id: str | None,
task_run_id: str | None,
) -> list[dict[str, str]]:
"""Build the related-resource list tying the event to the Prefect task run.
The ids are used by Prefect's UI to draw the event on the correct
flow-run / task-run timeline. Empty strings are omitted so we do not
emit malformed related-resource entries.
"""
related: list[dict[str, str]] = []
if flow_run_id:
related.append(
{
"prefect.resource.id": f"prefect.flow-run.{flow_run_id}",
"prefect.resource.role": "flow-run",
}
)
if task_run_id:
related.append(
{
"prefect.resource.id": f"prefect.task-run.{task_run_id}",
"prefect.resource.role": "task-run",
"prefect.resource.name": task_name,
}
)
return relatedreceipt_id is a Chio receipt id: 64 lowercase hex characters, enforced by the receipt schema's pattern, and elided in the middle in the example. timestamp is copied from the receipt as Unix seconds, an integer.
Retry conditions
Without configured retries, a refusal ends the task. When you enable retries, filter refusals deliberately, and select the case where a guard reads changing state with a retry_condition_fn.
def retry_on_rate_limit(task, task_run, state) -> bool:
exc = state.result(raise_on_failure=False)
if isinstance(exc, PermissionError):
err = getattr(exc, "chio_error", None)
if err is not None:
return err.guard == "RateLimitGuard"
return False
@chio_task(
tool_server="web",
retries=3,
retry_delay_seconds=30,
retry_condition_fn=retry_on_rate_limit,
)
def search_prices(sku: str) -> list[dict]: ...On a policy deny, the wrapper raises PermissionError with no from clause, so __cause__ is unset; on a sidecar 403, the cause is a ChioDeniedError. Only chio_error is populated on both paths.
Argument redaction
Before parameters go to the sidecar, the decorator runs them through chio_adapter_base.redact.bind_and_redact. Positional and keyword arguments are bound to their names, and protected body fields, such as chio_file_write.content and chio_file_edit.patch, become {"omitted": True, "byte_count": N}. The task body runs with the original arguments; receipts and Prefect event payloads carry the stub.
from chio_adapter_base.redact import RedactionPolicy
FILESYSTEM_SCOPE = ChioScope(grants=[
ToolGrant(server_id="workspace", tool_name="chio_file_write",
operations=[Operation.INVOKE]),
])
# Both decorators accept redaction_policy. Omit it for the chio default
# (which stubs chio_file_write.content / chio_file_edit.patch). A custom
# policy fully replaces the default.
@chio_task(
tool_server="workspace",
tool_name="chio_file_write",
redaction_policy=RedactionPolicy.chio_default(),
)
def write_file(path: str, content: bytes) -> None:
...
# A @chio_flow's redaction_policy becomes the default for every enclosed
# task that does not set its own.
@chio_flow(
scope=FILESYSTEM_SCOPE,
capability_id=os.environ["CHIO_FILESYSTEM_CAPABILITY_ID"],
chio_client=authority,
tool_server="workspace",
redaction_policy=RedactionPolicy.chio_default(),
)
def fs_pipeline(path: str, content: bytes) -> None:
write_file(path, content)The effective policy resolves per-task redaction_policy first, then the enclosing flow's policy, then RedactionPolicy.chio_default().
Async and sync tasks
The decorator preserves the function's signature. An async def becomes an async Prefect task. A plain def becomes a sync task whose evaluation runs in a throwaway event loop (asyncio.run), which blocks the calling worker thread until the verdict arrives; inside that loop the body runs on a thread through asyncio.to_thread(fn, *args, **kwargs), so a long body never blocks the loop.
@chio_task(tool_server="web")
async def search_prices(sku: str) -> list[dict]:
return await external_search.run(sku)
@chio_task(tool_server="web")
def summarize_prices(listings: list[dict]) -> dict:
return analyzer.run(listings)Testing with mock clients
The SDK's testing helpers include allow_all() and deny_all() mock clients. Inject one through chio_client= to unit-test a flow without a sidecar.
import pytest
from chio_sdk import ChioScope, Operation, ToolGrant
from chio.testing import allow_all, deny_all
from chio_prefect import chio_task, chio_flow
TEST_SCOPE = ChioScope(grants=[
ToolGrant(server_id="pricing", tool_name="quote", operations=[Operation.INVOKE]),
])
PRICES = {"sku-118": "24.00", "sku-204": "58.00", "sku-301": "140.00"}
def test_pipeline_happy_path():
client = allow_all()
@chio_task(tool_server="pricing", chio_client=client)
def quote(sku: str) -> str:
return PRICES[sku]
@chio_flow(
scope=TEST_SCOPE,
capability_id="cap-test",
tool_server="pricing",
chio_client=client,
)
def pipeline() -> str:
return quote("sku-204")
assert pipeline() == "58.00"
def test_pipeline_denied():
client = deny_all(reason="budget exceeded")
# capability_id is required on a task called outside a flow: without one
# the decorator raises ChioPrefectConfigError before the sidecar is asked.
@chio_task(tool_server="pricing", capability_id="cap-test", chio_client=client)
def quote(sku: str) -> str:
return PRICES[sku]
with pytest.raises(PermissionError):
quote("sku-204")Mediated evaluation
A client that evaluates through the mediated route reserves a hold and issues an execution nonce. Trusted tool consumption and reconciliation then complete the operation, and the execution receipt comes from that completion.
Package layout
sdks/python/chio-prefect/
pyproject.toml # deps: the Chio Python SDK, chio-adapter-base, prefect >= 3, pydantic
src/chio_prefect/
__init__.py # chio_task, chio_flow, errors, events
decorators.py # task and flow wrappers; flow-scope ContextVar
events.py # Prefect event emission
errors.py # ChioPrefectError, ChioPrefectConfigError
py.typed
tests/
test_task_decorator.py # decorators, deny translation, event emission
test_flow_attenuation.py # flow scope and the task-scope subset check
test_redaction.py # the redaction policy