BuildEvent-driven
AWS Lambda
Run Chio as a Lambda Extension to evaluate serverless tool calls and buffer their signed receipts.
Why Lambda Through Chio
Lambda's native authorization model is IAM: coarse, role-based, and attached to the function, not an invocation. This is useful for permissions the function itself needs to act in AWS, but it does not answer the agent governance question: does this specific caller, with this specific capability token, have scope to invoke this specific tool right now, within their remaining budget, and passing all guards? Chio layers that question on top of IAM without replacing it.
| Lambda alone | Lambda + Chio |
|---|---|
| IAM role-based authorization on the function | Capability-scoped, time-bounded, per-tool authorization per invocation |
| CloudWatch logs with structured fields | Merkle-committed, signed receipt log independent of CloudWatch |
| No tool-level policy API | Guard pipeline evaluates each invocation with evidence |
| Binary allow or deny semantics at the gateway | Budget-aware, scope-narrowing, conditional access |
| No cross-invocation audit trail | Receipt chain links related invocations in a workflow |
The Extension Model
Lambda Extensions run as co-processes in the same execution environment as the function. They start during the environment's INIT phase, receive lifecycle events during every INVOKE, and get one last hook on SHUTDOWN to drain buffered state. An extension does not replace the handler; it runs beside it. Chio uses a co-process that starts on cold start, answers evaluation calls on a localhost HTTP port during invocations, and flushes buffered receipts to DynamoDB before the environment is torn down.
Extension Lifecycle
The extension participates in three lifecycle phases, and each one does a specific job.
INIT
When a cold start fires, the extension registers with the Lambda Extensions Runtime API for the INVOKE and SHUTDOWN events and binds the evaluator on CHIO_EXTENSION_ADDR (default 127.0.0.1:9090). It fails closed: a missing CHIO_RECEIPT_TABLE, an unreachable Runtime API, or an unreachable DynamoDB makes the binary exit non-zero instead of running without a receipt sink. Every subsequent invocation reuses the warm process.
INVOKE
During an invocation, the handler calls POST /v1/evaluate on the local evaluator, directly or through the chio-lambda-python client or its @chio_tool decorator, and acts on the verdict. The extension records the receipt and buffers it for batch persistence.
SHUTDOWN
SHUTDOWN is the only flush. The INVOKE arm of the lifecycle loop logs the request id and does nothing else (src/lifecycle.rs:190-196); when the SHUTDOWN event arrives the hook drains the buffer and issues BatchWriteItem in chunks of 25. Unprocessed or throttled items are retried up to five times with backoff at 100, 200, 400, 800 and 1600 milliseconds, and the whole hook is wrapped in a 1.5 second timeout so the extension returns to the Runtime API before Lambda force-kills it (src/lifecycle.rs:206-210, src/dynamodb_flush.rs:43-46,121-153,210-215).
The buffer is bounded, and overflow is dropped
RECEIPT_BUFFER_CAPACITY = 1024 and are written with try_send. There is no buffer-full flush. When the channel is full the evaluator still answers the caller, logs receipt buffer full or closed; receipt dropped, and the receipt is gone (src/main.rs:419-425). A warm environment that serves more than 1024 evaluations between one cold start and its SHUTDOWN loses the excess. Size the expected invocations-per-environment against that ceiling before treating the DynamoDB table as a complete log.Cold-Start Optimization
Cold start is the dominant latency concern with any Lambda Extension. The extension is a small Rust binary, cross-compiled for arm64 and x86_64. Pre-publishing it as a Lambda layer avoids a per-cold-start download, and because the evaluator listens on loopback, warm-path evaluate calls stay inside the execution environment with no network hop.
Configuration and Evaluator API
The extension reads a small set of environment variables and serves a minimal JSON-over-HTTP evaluator so any language can call it with its standard library alone.
| Variable | Required | Default | Meaning |
|---|---|---|---|
CHIO_RECEIPT_TABLE | yes | none | DynamoDB table receipts are flushed into. Absent, the extension fails closed and exits non-zero. |
CHIO_TRUSTED_ISSUERS | yes | none | JSON array of hex-encoded issuer public keys. This is the whole issuer set: the evaluator reads it from the deployment environment and from nowhere else. Absent, every evaluation denies with missing CHIO_TRUSTED_ISSUERS deployment configuration. |
CHIO_CAPABILITY_TOKENS_JSON | conditional | none | JSON object mapping capability id to capability token. The evaluator reads it only when the request carries no inline capability. Absent in that case, the evaluation denies with missing CHIO_CAPABILITY_TOKENS_JSON and no inline capability token. |
CHIO_EXTENSION_ADDR | no | 127.0.0.1:9090 | Local socket address for the evaluator. |
RUST_LOG | no | chio_lambda_extension=info,warn | Tracing filter. |
The HTTP API has a health probe and one evaluation endpoint. It is plain JSON over HTTP/1.1 on loopback, so a handler in any runtime can call it with its standard library and no Chio dependency:
GET /health
GET /chio/health
-> {"status": "ok", "extension": "chio"}
POST /v1/evaluate
request: {capability_id, capability, tool_server, tool_name,
scope, arguments, metadata, trusted_issuers}
response: {receipt_id, decision, authorized, authoritative,
receipt_kind, boundary_class, reason, metadata,
capability_id, tool_server, tool_name, timestamp}Every request field defaults to empty, so a caller sends only what it has. The capability may travel in the body as capability or in the x-chio-capability header as JSON; when only the token is sent, the extension reads capability_id off it. trusted_issuers is accepted for wire compatibility and ignored: the issuer set comes from CHIO_TRUSTED_ISSUERS in the deployment environment and from nowhere else.
{
"capability_id": "cap-01HW...",
"tool_server": "tools.example",
"tool_name": "database-query",
"scope": "db:read",
"arguments": { "sql": "SELECT 1" }
}The response is the same shape on allow and on deny. Only decision and reason move, which is what makes the caller's branch trivial to write in a runtime with no client library:
{
"receipt_id": "01908f4a-...",
"decision": "deny",
"authorized": false,
"authoritative": false,
"receipt_kind": "trace_observation",
"boundary_class": "detect_only",
"reason": "missing tool_name",
"metadata": null,
"capability_id": "cap-01HW...",
"tool_server": "tools.example",
"tool_name": "",
"timestamp": 1713225600
}The deny reasons come out of evaluate_request in this order:
missing tool_servermissing tool_name- A capability-resolution failure. This branch sits before the
capability_idchecks and its reason is formatted, not fixed:invalid capability token: ...for a malformed inline token, or, when the request carries none,missing CHIO_CAPABILITY_TOKENS_JSON and no inline capability token,invalid CHIO_CAPABILITY_TOKENS_JSON: ...,unknown capability_id ..., orinvalid capability token for ... missing capability_idcapability_id does not match resolved capability token- An issuer-set failure:
missing CHIO_TRUSTED_ISSUERS deployment configurationwhen the variable is unset,invalid CHIO_TRUSTED_ISSUERS: ...orinvalid trusted issuer ...when it will not parse, and the baremissing trusted_issuerswhen it parses to an empty array - Whatever reason capability evaluation itself returns, falling back to
capability evaluation denied the request
The extension is detect-only, so no verdict it returns is an allow
authoritative: false, authorized: false, receipt_kind: "trace_observation", and boundary_class: "detect_only" as literals (src/main.rs:392-405). The extension resolves the capability token, checks it against the issuer set in CHIO_TRUSTED_ISSUERS, and writes a trace receipt; it runs no guards and it authorizes nothing.That has a consequence worth stating before the client examples below. EvaluateVerdict.allowed (sdks/lambda/chio-lambda-python/src/chio_lambda/client.py:57-66) is true only when decision == "allow" and authorized and authoritative and receipt_kind == "mediated_decision" and boundary_class == "prevent", and denied is its negation. Against the shipped extension the last four are never satisfied, so denied is always true. Read the examples below as the shape of the fail-closed branch, not as a gate that can open: the extension observes, and an authoritative decision point in front of the function is what allows. The Python client is written that way on purpose, so a caller that later points it at a real mediating kernel keeps the same code.
Using the Extension from Python
The companion client is chio-lambda-python, which imports as chio_lambda. It is a thin, synchronous httpx-based client (Lambda handlers are typically synchronous) and is fail-closed: an unreachable extension, a malformed response, or any non-authoritative verdict all surface as a denial. There is no record() call, the extension buffers and flushes the receipt itself.
pip install chio-lambda-pythonfrom chio_lambda import ChioLambdaClient
client = ChioLambdaClient() # defaults to http://127.0.0.1:9090
def handler(event, context):
verdict = client.evaluate(
capability_id=event["chio_capability_id"],
tool_server="tools.example",
tool_name="database-query",
scope="db:read",
arguments={"sql": event["body"]},
)
if verdict.denied:
return {
"statusCode": 403,
"body": json.dumps({
"error": "capability_denied",
"reason": verdict.reason,
"receipt_id": verdict.receipt_id,
}),
}
result = execute_query(event["body"])
return {
"statusCode": 200,
"body": json.dumps(result),
"headers": {"X-Chio-Receipt": verdict.receipt_id},
}The @chio_tool decorator wraps a handler so evaluation runs before the body. It resolves capability_id from an explicit kwarg, then event["chio_capability_id"] (key configurable via capability_event_key), then $CHIO_CAPABILITY_ID (configurable via capability_env). A deny or an unreachable extension raises ChioLambdaError and the wrapped body never runs.
from chio_lambda import chio_tool
@chio_tool(
scope="db:read",
tool_server="tools.example",
tool_name="database-query",
)
def handler(event, context, capability_id, verdict):
# Body runs only on an authoritative allow, which the detect-only
# extension never returns. The decorator injects capability_id and
# verdict when the signature declares them.
return run_query(event["body"])Receipt Persistence
Lambda execution environments are ephemeral, so receipts need a durable home before the environment is recycled. The shipped extension flushes to exactly one sink: DynamoDB. Each item is keyed on receipt_id (partition) and timestamp (sort), and contains capability_id, tool_server, tool_name, decision, reason, and a canonical-JSON payload. The extension does not create the table; provision it in your IaC.
ReceiptTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: receipt_id
AttributeType: S
- AttributeName: timestamp
AttributeType: N
KeySchema:
- AttributeName: receipt_id
KeyType: HASH # partition key
- AttributeName: timestamp
KeyType: RANGE # sort keyIAM Integration
The extension shares the function's execution role, so its DynamoDB writes run with the same IAM identity the function has. There is no separate credential pathway and no extra secret to manage. The only permission the extension needs is write access to the receipt table:
# Permission the chio extension needs (attached to the function role)
- Effect: Allow
Action:
- dynamodb:BatchWriteItem # receipt flush on SHUTDOWN / buffer-full
Resource: !GetAtt ReceiptTable.ArnSAM Template
Publish the layer with aws lambda publish-layer-version --layer-name chio-kernel-extension, then reference its ARN, for example arn:aws:lambda:us-east-1:000000000000:layer:chio-kernel-extension:42, and attach it to any function that should be governed.
Resources:
ChioExtensionLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: chio-kernel-extension
ContentUri: dist/chio-extension-arm64.zip
CompatibleRuntimes:
- python3.11
- python3.12
- python3.13
- nodejs20.x
- nodejs22.x
CompatibleArchitectures:
- arm64
- x86_64
ToolFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handler.handler
Runtime: python3.13
Architectures: [arm64]
Layers:
- !Ref ChioExtensionLayer
Environment:
Variables:
CHIO_RECEIPT_TABLE: !Ref ReceiptTable
Policies:
- DynamoDBCrudPolicy: { TableName: !Ref ReceiptTable }Package Layout
sdks/lambda/
chio-lambda-extension/ # Rust binary, compiles to a Lambda Extension
src/main.rs # extension entry point
src/lifecycle.rs # INVOKE / SHUTDOWN lifecycle loop
src/dynamodb_flush.rs # buffered receipt flush to DynamoDB
scripts/package-layer.sh
chio-lambda-python/ # deps: httpx, chio-sdk-python
src/chio_lambda/client.py # ChioLambdaClient, EvaluateVerdict, ChioLambdaError
src/chio_lambda/decorators.py # @chio_toolOpen Questions
- Guards and budgets. The evaluator resolves the capability token, checks it against the deployment issuer set, and runs
evaluate_capabilitywith an empty guard set and no metering. Scope narrowing, budget reservation, and the guard pipeline belong to the authoritative decision point in front of the function, which is why every verdict here isdetect_only. Whether a co-process that cannot hold budget state across a recycled environment should ever carry them is the open question. - Provisioned concurrency. With provisioned concurrency, the extension is always warm. How it should refresh policy once evaluation is wired, on a background timer or a miss-driven basis, is open.
- SnapStart (Java). Lambda SnapStart checkpoints the JVM after INIT. The extension state must be checkpoint-safe: no open sockets, no time-dependent state at checkpoint time.
- Multi-function workflows. For Step Functions orchestrating multiple Lambdas, should each function carry a grant token the orchestrator acquired, similar to the Temporal
WorkflowGrantmodel?
Next Steps
- Envoy ext_authz · front any HTTP service with Chio via the Envoy filter
- Kafka · governance for event-driven Lambda fan-ins via the streaming adapter
- Receipt Dashboard · visualize receipts flushed from the DynamoDB receipt table