Chio/Docs
LOGIN · JOIN

Temporal

Admit every Temporal activity under one workflow grant, end refused activities without retries, and collect each run's receipts.

Guide chio@5f63e6e

On this page

A Temporal workflow can carry one bounded grant into every activity it schedules. ChioActivityInterceptor, registered on the activity worker, has the kernel evaluate each activity execution under that grant before the activity body runs.

Temporal keeps scheduling, retries with backoff, history, visibility, and saga compensation. Workflow workers orchestrate and never call Chio, so replay stays deterministic. The activity worker is the enforcement point, because Temporal treats activities as the side-effect boundary, and the Chio sidecar runs beside it: in the same pod on Kubernetes, on the same host on a VM.

Start
Temporal through the kernelChoose an outcome.

r1call_toolr2read_databaser3send_email

The interceptor admits each activity before it runs

Workflow workers orchestrate and never call Chio, so replay stays deterministic. On the activity worker, ChioActivityInterceptor evaluates every activity execution under the registered WorkflowGrant, and only an allow runs the activity body. The mediated call.

Activities of one workflow run, each evaluated by ChioActivityInterceptor on the activity worker before its body runs. The refused send_email is illustrative. Saga compensation and sidecar transport retries are left out.

A refusal raises a non-retryable error, so Temporal ends the activity instead of retrying an operation the kernel refused. Allowed or refused, each executed activity adds a step to one receipt for its run.

1. Install the adapter on the activity worker

chio-temporal depends on the Chio Python SDK, chio-adapter-base>=0.2.0,<0.3, temporalio>=1.7,<2, and pydantic>=2.5,<3. Install it where your activity worker runs.

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-temporal
  3. Check the import
    Terminal · the same Python environment
    python -c 'from chio_temporal import ChioActivityInterceptor, WorkflowGrant; print("Temporal adapter ready")'
    Expected checkpoint

    Temporal adapter ready

    The adapter imports. ChioActivityInterceptor, registered on a worker, evaluates each activity under its WorkflowGrant before the activity runs.

You should see Temporal adapter ready.

2. Register the interceptor and the workflow's grant

Register one interceptor on the worker, then register a WorkflowGrant for each workflow before it starts. In this excerpt, CHIO_TEMPORAL_CAPABILITY_FILE names a signed capability token covering quote and publish_price on pricing and read_order on orders; your application supplies the workflow, the activities, the authority client, and the receipt sink.

worker.pypython
import asyncio
import os
from pathlib import Path

from application import (
    RepricingWorkflow, authority, quote, my_receipt_sink, read_order, publish_price,
)
from chio import CapabilityToken, ChioScope, Operation, ToolGrant
from chio_temporal import ChioActivityInterceptor, WorkflowGrant
from temporalio.client import Client
from temporalio.worker import Worker


def scope_for(*tools: str, server_id: str = "pricing") -> ChioScope:
    """Local helper. The SDK ships ChioScope; scope_for is yours."""
    return ChioScope(
        grants=[
            ToolGrant(server_id=server_id, tool_name=tool, operations=[Operation.INVOKE])
            for tool in tools
        ]
    )


async def main() -> None:
    client = await Client.connect("localhost:7233")

    interceptor = ChioActivityInterceptor(
        chio_client=authority,
        sidecar_url="http://127.0.0.1:9090",
        # Fallback tool_server id when an activity has no explicit mapping.
        default_tool_server="pricing",
        # Per-activity-type mapping. Activities usually map 1:1 to a tool server.
        activity_tool_server_map={
            "quote":         "pricing",
            "read_order":    "orders",
            "publish_price": "pricing",
        },
        # Optional: drain finalized workflow receipts to a sink.
        receipt_sink=my_receipt_sink,
        # Optional: override argument redaction. Defaults to
        # RedactionPolicy.chio_default() when omitted.
        redaction_policy=None,
    )

    # Before starting the workflow, register the grant for its workflow_id.
    token = CapabilityToken.model_validate_json(
        Path(os.environ["CHIO_TEMPORAL_CAPABILITY_FILE"]).read_text()
    )
    grant = WorkflowGrant(workflow_id="reprice-order-77", token=token, tool_server="pricing")
    interceptor.register_workflow_grant(grant)

    worker = Worker(
        client,
        task_queue="pricing-tasks",
        workflows=[RepricingWorkflow],
        activities=[quote, read_order, publish_price],
        interceptors=[interceptor],
    )
    await worker.run()


asyncio.run(main())

The interceptor looks up the grant when the first activity for a (workflow_id, run_id) pair executes. It evaluates each activity through the sidecar under the grant's capability and records a WorkflowStepReceipt on that run's WorkflowReceipt. State is keyed on the pair, so concurrent workflows on one worker keep separate step lists.

Scope travels in the grant's CapabilityToken.scope; the interceptor takes no scope_map. activity_tool_server_map maps activity types to tool server ids, and default_tool_server covers the rest. Keep every Chio call inside activities, because workflow code must stay deterministic for replay.

3. Narrow one activity below the workflow grant

A grant binds to a workflow_id and, with optional run_id pinning, to one execution; otherwise it covers every run of that id. attenuate_for_activity hands one activity type a narrower grant through an override hook.

worker.pypython
narrower = await grant.attenuate_for_activity(
    authority,
    new_scope=scope_for("read_order", server_id="orders"),
    tool_server="orders",
)

# Register a per-activity-type hook that returns the narrower grant.
interceptor.register_activity_grant_override(
    "read_order",
    lambda info: narrower,
)

The hook receives Temporal's activity.Info for each execution, and returning None falls back to the workflow grant. The interceptor checks that the override scope is a subset of the workflow grant's scope and raises ChioTemporalConfigError otherwise.

attenuate_for_activity checks the subset rule, then delegates to the client's attenuate_capability, which signs with the parent subject's key. Keep that key with its owner, never in the sidecar: use a signer-owning client, or register an already issued narrower grant with the same hook, preserving the workflow binding and parent scope.

4. Match a refusal and run your compensation

On a deny, the interceptor records the deny receipt on the run's WorkflowReceipt and raises a non-retryable ApplicationError of type "ChioCapabilityDenied". The workflow receives an ActivityError whose cause is that error.

transfer_workflow.pypython
from datetime import timedelta
from temporalio import workflow
from temporalio.exceptions import ActivityError, ApplicationError
from chio_temporal import DENIED_ERROR_TYPE

@workflow.defn
class TransferWorkflow:
    @workflow.run
    async def run(self, transfer: Transfer) -> TransferResult:
        compensations: list[tuple[str, list]] = []
        debit = await workflow.execute_activity(
            debit_account,
            args=[transfer.source, transfer.amount],
            start_to_close_timeout=timedelta(seconds=30),
        )
        compensations.append(("credit_account", [transfer.source, transfer.amount]))

        try:
            credit = await workflow.execute_activity(
                credit_account,
                args=[transfer.destination, transfer.amount],
                start_to_close_timeout=timedelta(seconds=30),
            )
        except ActivityError as exc:
            cause = exc.cause
            if isinstance(cause, ApplicationError) and cause.type == DENIED_ERROR_TYPE:
                for name, args in reversed(compensations):
                    await workflow.execute_activity(
                        name, args=args,
                        start_to_close_timeout=timedelta(seconds=30),
                    )
            raise

        return TransferResult(debit=debit, credit=credit)

The excerpt assumes your Transfer types and account activities, and each compensating activity needs its own valid authority. Sidecar transport faults raise ApplicationError(type="ChioSidecarError", non_retryable=False), so Temporal applies its usual retry policy to them and to nothing policy refused.

5. Close the run and flush its workflow receipt

Each executed activity appends a WorkflowStepReceipt. When the workflow completes, finalize_workflow closes the envelope and flush_workflow_receipt forwards it to the configured sink.

finalize.pypython
# At workflow completion (typically from a "finalize" activity).
interceptor.finalize_workflow(
    workflow_id="reprice-order-77",
    run_id=run_id,
    outcome="success",   # or "failure" | "cancelled"
)
envelope = await interceptor.flush_workflow_receipt(
    workflow_id="reprice-order-77",
    run_id=run_id,
)
# envelope["version"] == "chio-temporal/v1"
# envelope has: workflow_id, run_id, parent_workflow_ids, started_at,
# completed_at, outcome, step_count, allow_count, deny_count, steps, metadata

The envelope holds one step per executed activity, with the allow and deny counts beside them. Step receipts issued by the sidecar are signed. The envelope that groups them is unsigned, and a deny the sidecar answers with HTTP 403 is recorded as a synthetic unsigned step.

The interceptor returns the activity's result unchanged, so no receipt id rides back in the value. The id lands on the in-memory WorkflowStepReceipt, and the receipt body stays in the Chio receipt store. On a deny, the raised error carries ChioTemporalError.to_dict() as its details, including the receipt id and the decision, and Temporal persists those details in history like any failure.

Temporal and the kernel divide the work at each activity

Temporal schedules each activity and retries its failures; the kernel admits or refuses the activity before its body runs.

ConcernTemporalWith the interceptor
RetriesActivities retry on failureA refusal is non-retryable
AuthorizationPer namespace or task queuePer tool and scope, time-bounded, with per-activity overrides
RecordAppend-only workflow historyOne WorkflowReceipt per run
CompensationSaga logic you writeMatches on ChioCapabilityDenied

An interceptor around Python code adds admission. Isolation of the activity's own process comes from the host, as the mediation boundary describes, and recovery of its external effects comes from running governed tools in hosted process workers.

Adapter reference

Public API

  • ChioActivityInterceptor. Worker-level Interceptor that gates activity execution. A deny raises ApplicationError(type="ChioCapabilityDenied", non_retryable=True).
  • WorkflowGrant. A capability token pinned to a Temporal workflow_id, and optionally a run_id. Activities inherit the grant, which retains the full token. attenuate_for_activity delegates narrowing to the selected client and requires the parent subject signer.
  • WorkflowReceipt, WorkflowStepReceipt. The aggregate of per-activity receipts in one workflow run, serialized to the chio-temporal/v1 JSON envelope.
  • build_chio_worker. Builder that mints a grant, constructs the interceptor, and returns (worker, interceptor, grant).
  • ActivityGrantOverride. Callable signature for the per-activity hook registered through register_activity_grant_override.
  • DENIED_ERROR_TYPE. The string "ChioCapabilityDenied", the ApplicationError type on a deny, for saga logic to match.
  • ChioTemporalError, ChioTemporalConfigError. Error types.

Argument redaction

Activity parameters are redacted in the activity layer before the sidecar sees them, so workflow determinism is unaffected. Arguments arrive as parameters["args"]; the interceptor binds them through chio_adapter_base.redact.bind_and_redact and the DEFAULT_TOOL_POSITIONAL_NAMES registry, so positional bodies for tools such as chio_file_write and chio_file_edit are bound to their parameter names and stubbed.

The default policy replaces protected fields with {"omitted": true, "byte_count": N}, and the activity body still runs with the original arguments. The sidecar, every WorkflowStepReceipt, and any receipt_sink consumer see only the byte count. Pass redaction_policy to the constructor to change what is stubbed, and enforce constraints over raw payload bytes in your code before the activity is dispatched.

Convenience builder

build_chio_worker mints a capability or reuses a supplied one, constructs the interceptor, registers the grant, builds the worker, and returns the trio.

worker.pypython
from chio_temporal import build_chio_worker

worker, interceptor, grant = await build_chio_worker(
    client,
    task_queue="pricing-tasks",
    activities=[quote, read_order, publish_price],
    workflows=[RepricingWorkflow],
    chio_client=authority,
    workflow_id="reprice-order-77",
    # Reuse the existing capability; omit scope and subject.
    capability_id=token.id,
    default_tool_server="pricing",
    activity_tool_server_map={"read_order": "orders"},
)

await worker.run()

capability_id is keyword-only with no default, so leaving it out is a TypeError. The builder refuses only when both it and scope are absent. When both are present, scope wins: the builder mints a new token and moves the id you passed to grant_metadata["supplied_capability_id"]. To reuse a pre-minted id, pass it and leave scope and subject out; the builder wraps the id in a stand-in token whose only meaningful field is the id the interceptor evaluates against.

Responsibilities outside the interceptor

  • Approval inside an activity. The interceptor answers allow or deny and holds no activity open on pending_approval. Activity code that needs an approval calls the client itself and decides whether to await a Temporal Signal while a person approves. evaluate_tool_call_advisory returns an advisory observation; evaluate_tool_call_mediated, given a complete signed token, reserves a hold and issues an execution nonce, and trusted tool consumption and reconciliation complete the operation. A reservation is a hold, distinct from an execution receipt.
  • Budget reversal. A failed workflow leaves its budget charges in place. Reverse a charge with a compensating activity, as you compensate any other side effect.
  • Workers in other languages. The interceptor is Python. A temporal-sdk-core worker in another language reaches the same policy through the sidecar's language-neutral HTTP interface.
  • Multi-cluster replication. Temporal history replication carries neither the interceptor's in-memory step list nor the kernel's receipt store. Configure retention, receipt delivery, and recovery across worker or cluster replacement.

Package layout

Package layouttext
sdks/python/chio-temporal/
  pyproject.toml                # deps: the Chio Python SDK, chio-adapter-base, temporalio
  src/chio_temporal/
    __init__.py                 # public API
    interceptor.py              # ChioActivityInterceptor, ActivityGrantOverride, DENIED_ERROR_TYPE
    grants.py                   # WorkflowGrant (+ attenuate_for_activity)
    receipt.py                  # WorkflowReceipt, WorkflowStepReceipt, ENVELOPE_VERSION
    worker.py                   # build_chio_worker
    errors.py                   # ChioTemporalError, ChioTemporalConfigError
    py.typed
  tests/
    test_interceptor.py
    test_workflow_receipt.py
    test_redaction.py
    test_temporal_redaction_helpers.py

Next: admit each event a consumer reads