Chio/Docs
LOGIN · JOIN

Prefect

Decorate Prefect tasks so the kernel evaluates each one before its body runs and its decision lands on the flow-run timeline.

Guide chio@5f63e6e

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.

Start
Prefect through the kernelChoose an outcome.

r1search_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.

Task runs of one research_pipeline flow, each evaluated by @chio_task before its body runs. The refused second search is illustrative. Flow-scope attenuation and retry conditions are left out.

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.

  1. Create a Python environment
    Terminal · your project
    python3 -m venv .venv
    source .venv/bin/activate
  2. Install the SDK and the adapter
    Terminal · your project, with .venv active
    python -m pip install chio-sdk chio-prefect
  3. Check the import
    Terminal · the same Python environment
    python -c 'from chio_prefect import chio_flow, chio_task; print("Prefect adapter ready")'
    Expected checkpoint

    Prefect 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.

pipeline.pypython
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.

pipeline.pypython
# 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.

run_pipeline.pypython
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:
        raise

You 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.

EventEmittedPayload
chio.receipt.allowOn allow, before the bodyReceipt id, capability id, tool server, tool name, timestamp
chio.receipt.denyOn deny, before the bodyReceipt id, guard, reason, capability id, tool server, tool name
chio.receipt.allow eventpython
# 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

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.

sdks/python/chio-prefect/src/chio_prefect/events.py85-124python
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 related

receipt_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.

pipeline.pypython
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.

fs_pipeline.pypython
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.

pipeline.pypython
@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.

test_pipeline.py · mock-client behaviorpython
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

Package layouttext
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

Next: compensate refused work in a durable workflow