Operations
This page explains how to run agent-sidecar day to day: where logs go, how to read the audit trail, how to fix common problems, and how to change charters and certificates. It ends with a production checklist and the full list of known limitations.
Commands on this page use the names in the code: the sidecar is attached, the CLI is attache, and the CA is embassy. Architecture explains the naming. The examples use the demo namespace attache-demo and the kubectl context minikube. Change them for your cluster.
- Where logs go
- The audit record
- Reading logs
- Troubleshooting
- Changing a charter
- Rotating certificates
- Budgets on restart and with replicas
- Production checklist (recommendations)
- Known limitations
Where logs go
The attached container writes three kinds of lines.
| Stream | Written by | Goes to | How to recognise a line |
|---|---|---|---|
| Audit records | attached |
stdout when audit.sink: stdout (the default), or a file when audit.sink: file |
One JSON object per line. It always starts with {"ts":. |
| Sidecar logs | attached |
stderr | A timestamp, a level such as INFO or WARN, then a message. These lines can contain ANSI colour codes. |
| Runtime logs | attache-runtime, forwarded by attached |
stderr of attached |
Every line starts with [runtime] . |
The runtime is a child process of the sidecar. The sidecar reads the runtime’s stdout and stderr. It writes every line to its own stderr with the prefix [runtime] . So stdout carries audit records only.
Kubernetes keeps stdout and stderr of a container in one log. So kubectl logs -c attached shows all three streams mixed together. attache logs keeps only the lines that parse as audit records.
flowchart LR
RT["attache-runtime (child process)"] -->|"model_call records"| SC["attached"]
RT -->|"stdout and stderr lines"| SC
SC -->|"audit records, sink stdout"| OUT["stdout"]
SC -->|"audit records, sink file"| FILE["audit file in the container"]
SC -->|"sidecar logs and runtime lines"| ERR["stderr"]
OUT --> KL["kubectl logs -c attached"]
ERR --> KL
FILE --> EX["kubectl exec ... cat"]
Log level
The sidecar log level comes from the RUST_LOG environment variable. The image and the demo Deployments set RUST_LOG=info. Set RUST_LOG=debug on the attached container for more detail. The runtime always logs at INFO. It has no setting for this.
Audit sink: stdout or file
There are two sinks: stdout and file. stdout is the default. You set the sink in the charter. This snippet is partial: it shows only the audit block of a charter.
audit:
sink: file
file: /var/log/attache/audit.jsonl
redact: []
What you need to know about the file sink:
audit.sink: fileneedsaudit.file. Without it the charter is invalid (audit.sink: file requires audit.file).- The sidecar creates the parent directory and appends to the file. It opens the file at startup. If it cannot, it exits with
open audit file. - The image makes
/var/log/attachewritable for its user (uid 10001). - The demo Deployments mount no volume at
/var/log/attache. The file lives in the container’s writable layer. It is lost when the container restarts. Mount a volume there if you use the file sink. - With the file sink, stdout has no audit records.
kubectl logsandattache logs <service>show no records. Read the file withkubectl exec(see Reading logs). - The sidecar does not rotate the file.
- The sidecar ignores write errors on the sink. If the disk is full, records are lost without a warning.
Local runs
make local-demo runs the sidecars on your machine. It writes these files:
| File | Content |
|---|---|
.local/logs/<svc>-audit.jsonl |
stdout of the sidecar: audit records only |
.local/logs/<svc>-attached.log |
stderr of the sidecar: sidecar logs and [runtime] lines |
.local/logs/<svc>-app.log |
output of the demo app |
cat .local/logs/orders-audit.jsonl .local/logs/inventory-audit.jsonl | ./target/debug/attache logs --file -
The audit record
Each record is one JSON object on one line. Only attached writes records. The runtime reports its model_call records to the sidecar over the ReportEvent call, and the sidecar writes them. The field order is fixed, so grep and people see the same layout. The full reference is CONTRACT.md section 6.
Fields
| Field | Type | Meaning |
|---|---|---|
ts |
string | When the record was made, normally when the step finished. UTC, RFC 3339 with milliseconds, for example 2026-10-20T13:05:24.710Z. |
kind |
string | One of nine kinds. See Record kinds. |
request_id |
string | A UUID. One per top-level invocation. It is the same on every hop. |
trace_id |
string | W3C trace id, 32 hex characters. |
span_id |
string | The span of this hop, 16 hex characters. |
parent_span_id |
string or null | The span that called this one. null for a root span. |
service |
string | The service of the sidecar that wrote the record. |
namespace |
string | The namespace of that service. |
agent |
string or null | The agent this record belongs to. On a callee’s inbound service_call, this is the calling agent from the other pod. null when no agent is known. |
caller |
string | local (this pod: the app or a local agent), a SPIFFE id such as spiffe://attache/ns/attache-demo/svc/orders, or unknown (the certificate was refused). |
caller_agent |
string or null | The agent that made the call, when an agent made it. |
callee |
string | What was called. The format depends on the kind. |
direction |
string | inbound, outbound or internal. |
input |
JSON or string | What went in, after redaction and truncation. |
output |
JSON, string or null | What came out, after redaction and truncation. null when the step was denied or failed. |
input_sha256 |
string or null | SHA-256 (hex) of the full original input. null when the input is null. |
output_sha256 |
string or null | The same for the output. |
tokens |
object or null | {"input": n, "output": n} for model calls, agent calls and invocations. |
cost_usd |
number or null | Cost from the runtime’s price table. The mock model costs 0. |
latency_ms |
integer | How long the step took. |
decision |
string | allow or deny. |
rule |
string or null | The rule that decided, for example authz.services.deny-admin. |
reason |
string or null | A human-readable reason. Mostly set for denials. |
error |
string or null | Set when the step was allowed but failed. |
Record kinds
kind |
direction |
Written when | callee example |
|---|---|---|---|
agent_invocation |
inbound | An agent is invoked: by the app, by a peer sidecar, or by a local agent tool. Exactly one per invocation. It is written last, after the records of its steps. | ops-assistant |
model_call |
outbound | Each model request in the agent loop. The runtime reports it. The sidecar writes it. | anthropic:claude-opus-5-5 or mock |
tool_call |
internal | Each tool call from the runtime: allowed, denied or failed. | get_stock (the tool name) |
service_call |
internal (own app), outbound (to a peer), inbound (on the callee) | A service tool request was sent to the app or to a peer. The callee writes its own inbound record, also for denials. |
inventory GET /stock/WIDGET-1 |
agent_call |
outbound (remote agent), internal (local agent) | An agent tool call was sent. |
inventory/stock-analyst or order-triage |
http_call |
outbound | An http tool request was sent. |
GET https://example.com/items/1 |
guardrail |
internal | An input or output guardrail denied the invocation, or pii_redact rewrote the output (then decision: allow and rule: guardrail.pii_redact). |
the agent name |
budget |
internal | max_calls_per_minute or max_cost_usd_per_day denied an invocation. |
the agent name |
authz |
inbound | A mesh call was refused because the client certificate has no spiffe://attache/ URI SAN. The rule is authz.identity.invalid and caller is unknown. |
the agent, or <service> <METHOD> <path> |
Some details:
- A denial by
authz.callersis written as kindagent_invocationwithdecision: deny. It is not kindauthz. - A
guardrailorbudgetdenial writes two records: theguardrailorbudgetrecord and theagent_invocationrecord withdecision: deny. - A tool call that is denied on this sidecar, before anything is sent, writes only a
tool_callrecord. budget.max_depthwrites only theagent_invocationrecord.
decision, rule, reason and error
Read the four fields together:
decision |
error |
Meaning |
|---|---|---|
deny |
null |
Policy stopped the step. rule names the policy and reason says why. |
allow |
set | Policy let the step through, but it failed. Examples: peer down, app down, model error, unknown agent. |
allow |
null |
The step was allowed and worked. rule may name the rule that allowed it, for example authz.services.self-stock, or be null. |
Rule names come in families:
| Rule | Comes from |
|---|---|
authz.callers.default |
No authz.callers rule exists for the caller’s identity. |
authz.callers.<service> |
A rule exists for the identity, but it does not list the agent, or its caller_agents does not include the calling agent. For calls from the app the suffix is local, for example authz.callers.local. |
authz.services.<name> |
The named authz.services rule. With deny, one of its deny patterns matched. With allow, one of its allow patterns matched. |
authz.default.deny, authz.default.allow |
authz.default, because no authz.services pattern matched. |
authz.identity.invalid |
A mesh client certificate without a valid SPIFFE URI SAN. |
guardrail.<check> |
max_input_bytes, input_deny.<name>, output_deny.<name>, pii_redact, allowed_tools, allowed_services, tool_spec, path. |
budget.<limit> |
max_calls_per_minute, max_cost_usd_per_day, max_depth. |
model.refusal |
The model refused the request. |
agent.unknown |
The agent is not in the charter. The record has decision: allow and error set. |
Some failures name a rule only in the HTTP response: runtime.unavailable, model.error, budget.max_steps and budget.max_tokens_per_call. Their agent_invocation record has decision: allow, rule: null and the reason in error.
Linking records across pods
Two ids join the records of one call, across all pods:
request_idis a UUID. The first sidecar makes a new one for every request from its app. The app cannot set it. The sidecar sends it unchanged on every hop: asx-attache-request-idmesh metadata to peers, and as thex-attache-request-idheader to apps. One top-level invocation has onerequest_id.trace_idis a W3C trace id. The sidecar takes it from the app’straceparentheader when the header is valid. Otherwise it makes a new one. Every hop makes a newspan_id. Itsparent_span_idis the span of the hop before it.
If your gateway sends the same traceparent for many requests, they share one trace_id. Each still has its own request_id. Use --request to separate them.
sequenceDiagram
participant App as orders app
participant O as orders sidecar
participant I as inventory sidecar
participant IA as inventory app
App->>O: POST /v1/agents/ops-assistant/invoke with optional traceparent
Note over O: new request_id R. trace_id T from traceparent, or new.
O->>I: mesh call with traceparent (child span of T) and x-attache-request-id R
Note over I: same R and T, new span_id
I->>IA: GET /stock/WIDGET-1 with traceparent, x-attache-request-id, x-attache-caller, x-attache-agent
IA-->>I: 200
I-->>O: agent output
O-->>App: body has request_id R and trace_id T, headers traceparent and x-attache-request-id
Tips:
- The app gets both ids in the response body (
request_id,trace_id) and in the response headers (x-attache-request-id,traceparent). Log them in your app. - When an agent calls your app, the request carries
traceparent,x-attache-request-id,x-attache-callerandx-attache-agent. Log them too. Then you can join your app logs with the audit log. - Records are written when each step finishes. So step records come before the record of the step that started them.
attache logssorts byts, except when it follows.
Payload redaction and hashes
The sidecar does these steps, in this order, for every record it writes:
- Hash.
input_sha256andoutput_sha256are computed over the full original payload, before any redaction or truncation. For a string payload, the hash is SHA-256 of its UTF-8 bytes. For any other JSON value, it is SHA-256 of its compact JSON text. Anullpayload has anullhash. So you can prove later that a payload you hold is the one that passed, without the log storing it. audit.redactpaths. Each path is a dotted path that starts withinput.oroutput., for exampleoutput.body.warehouse_contact. The value there becomes"[redacted]". Arrays are walked. The pathinputoroutputalone redacts the whole payload. The charter validator does not check these paths. A path with another start does nothing.- Scrub repeated values. The values found at those paths (strings and numbers of 4 or more characters) are remembered for that
request_idfor 15 minutes. They are replaced with[redacted]wherever they appear as text in later records of the same request in that sidecar, for example inside a model prompt. - PII. With
guardrails.pii_redact: true, emails become[redacted:email]and phone numbers become[redacted:phone]. This applies to every string ininputandoutput. - Truncate. A payload larger than 8 KiB is cut to 8 KiB plus
…(truncated, N bytes). Truncation comes after redaction, so a cut never shows part of a redacted value.
This snippet is partial: it shows only the audit block of a charter.
audit:
sink: stdout
redact:
- input.context.customer_email # agent_invocation input, when the app sends a context
- output.body.warehouse_contact # service_call output: {"status": 200, "body": {...}}
Each sidecar applies the charter of its own service. A value that the inventory charter redacts can still appear in the orders log if the orders charter does not redact it.
What redaction does not cover:
- The prompt and the tool results sent to the model provider. The runtime sends them as they are.
- Tool results returned to the runtime. The model sees them as they are.
- Requests the sidecar forwards to apps and peers.
pii_redact changes the agent’s final output and the audit records. Nothing else.
Example record
This is the denial written by the inventory sidecar in the local demo. It is one line in the log. It is shown here formatted.
{
"ts": "2026-10-20T13:05:24.710Z",
"kind": "service_call",
"request_id": "b7e5832a-ad31-4f78-9215-8d103cc9ed07",
"trace_id": "e3af314822426057d5ee3179b51461b4",
"span_id": "2e77fa4388933dfb",
"parent_span_id": "d4ab404d3c0097b1",
"service": "inventory",
"namespace": "attache-demo",
"agent": "ops-assistant",
"caller": "spiffe://attache/ns/attache-demo/svc/orders",
"caller_agent": "ops-assistant",
"callee": "inventory POST /admin/reset",
"direction": "inbound",
"input": { "method": "POST", "path": "/admin/reset", "body": {} },
"output": null,
"input_sha256": "004b59a790029158af09d1ac3ccd89684b533043e26499e4e20ecdc1f18826e8",
"output_sha256": null,
"tokens": null,
"cost_usd": null,
"latency_ms": 0,
"decision": "deny",
"rule": "authz.services.deny-admin",
"reason": "POST /admin/reset on inventory matches deny pattern \"* /admin/*\"",
"error": null
}
The orders sidecar wrote a tool_call and a service_call record for the same call. They have the same request_id, the same trace_id and the same rule.
You can check the hash. The input is a JSON object, so the hash is over its compact JSON text:
printf '%s' '{"method":"POST","path":"/admin/reset","body":{}}' | shasum -a 256
# 004b59a790029158af09d1ac3ccd89684b533043e26499e4e20ecdc1f18826e8 -
On Linux, use sha256sum instead of shasum -a 256.
Reading logs
With kubectl
# Everything the sidecar wrote: audit records, sidecar logs and runtime lines
kubectl --context minikube -n attache-demo logs -l attache.io/service=inventory -c attached --tail=-1
# Only audit records
kubectl --context minikube -n attache-demo logs -l attache.io/service=inventory -c attached --tail=-1 \
| grep '^{"ts"'
# Only runtime lines (deploy/<name> picks one pod)
kubectl --context minikube -n attache-demo logs deploy/inventory -c attached | grep '^\[runtime\]'
# The previous run of the container, after a crash
kubectl --context minikube -n attache-demo logs deploy/inventory -c attached --previous
With -l, kubectl prints only the last 10 lines of each container unless you pass --tail. --tail=-1 prints everything.
If you have jq, you can filter the JSON:
kubectl --context minikube -n attache-demo logs -l attache.io/service=inventory -c attached --tail=-1 \
| grep '^{"ts"' | jq -c 'select(.decision == "deny") | {ts, service, kind, rule, reason}'
With the file sink, read the file inside the container:
kubectl --context minikube -n attache-demo exec deploy/inventory -c attached -- \
cat /var/log/attache/audit.jsonl | ./target/debug/attache logs --file - --denied
With the attache CLI
Build the CLI once with cargo build -p attache-cli. The binary is ./target/debug/attache. make logs builds it for you if it is missing.
./target/debug/attache logs orders,inventory -n attache-demo --context minikube
What it does:
- It runs
kubectl [--context C] logs -n NS -l <selector> -c attached --tail N --prefix=false, with-fwhen you follow. The selector isattache.io/service=<svc>for one service, orattache.io/service in (<svc>,<svc>)for several. - It keeps only lines that parse as audit records. Sidecar logs and
[runtime]lines are skipped. - It applies your filters.
- It sorts by
ts, unless you follow. - It prints one line per record.
Your pods need the label attache.io/service: <svc>, and the sidecar container must be named attached. Otherwise attache logs finds nothing.
| Flag | Default | What it does |
|---|---|---|
<service>[,<service>...] |
none | Services to read, by the label attache.io/service. Required unless you use --file. |
-n, --namespace |
attache-demo |
The namespace. |
--context |
kubectl’s current context | The kubeconfig context. |
--agent A |
none | Only records whose agent or caller_agent is exactly A. |
--trace T |
none | Only records whose trace_id starts with T. |
--request R |
none | Only records whose request_id starts with R. |
--kind K |
none | Only records of one kind, for example service_call. |
--denied |
off | Only records with decision: deny. |
-f, --follow |
off | Follow new records (kubectl logs -f). Records print as they arrive, not sorted. |
--raw |
off | Print the JSON lines instead of the table. |
--file F |
none | Read records from file F, or from stdin with -, instead of kubectl. The service argument is ignored. |
--tail N |
1000 |
How many lines kubectl fetches per container. |
Exit status: attache logs exits 1 if kubectl fails. It prints kubectl logs failed (...); no audit records were read. An empty table always means “no matching records”. It never hides a kubectl failure.
The table columns are: time (UTC, HH:MM:SS.mmm), service, agent, kind, direction, caller, → callee, decision, latency. Then, when present: tokens=<in>/<out> $<cost>, error=<text>, and always trace=<first 8 characters>. A SPIFFE caller is shortened to svc/<name>. The calling agent is shown in parentheses, except on an agent_invocation record whose caller is local. A denial shows DENY <rule>. A pii_redact record shows allow guardrail.pii_redact.
This is real output for the denied call of the local demo:
cat .local/logs/orders-audit.jsonl .local/logs/inventory-audit.jsonl \
| ./target/debug/attache logs --file - --trace e3af3148
13:05:24.709 orders ops-assistant model_call outbound local → mock allow 0ms tokens=101/9 $0.0000 trace=e3af3148
13:05:24.710 inventory ops-assistant service_call inbound svc/orders (ops-assistant) → inventory POST /admin/reset DENY authz.services.deny-admin 0ms trace=e3af3148
13:05:24.711 orders ops-assistant tool_call internal local (ops-assistant) → inventory_admin_reset DENY authz.services.deny-admin 0ms trace=e3af3148
13:05:24.711 orders ops-assistant service_call outbound local (ops-assistant) → inventory POST /admin/reset DENY authz.services.deny-admin 0ms trace=e3af3148
13:05:24.711 orders ops-assistant model_call outbound local → mock allow 0ms tokens=175/38 $0.0000 trace=e3af3148
13:05:24.711 orders ops-assistant agent_invocation inbound local → ops-assistant allow 3ms tokens=276/47 $0.0000 trace=e3af3148
The invocation itself is allow. The denied tool call did not fail it. The model got the denial as a tool error and answered.
More examples:
# One call across both pods
./target/debug/attache logs orders,inventory --context minikube --trace e3af3148
# One top-level invocation
./target/debug/attache logs orders,inventory --context minikube --request b7e5832a
# Every denial in the last 1000 lines of each pod
./target/debug/attache logs orders,inventory --context minikube --denied
# Follow one agent live
./target/debug/attache logs inventory --context minikube --agent stock-analyst -f
# Only model calls, as raw JSON
./target/debug/attache logs orders --context minikube --kind model_call --raw
Make shortcuts
| Target | Runs |
|---|---|
make logs |
attache logs orders,inventory -n $(NS) --context $(KUBE_CONTEXT). Add TRACE=<prefix> for --trace and AGENT=<name> for --agent. |
make logs-denied |
The same with --denied. |
NS defaults to attache-demo. KUBE_CONTEXT defaults to the value of PROFILE, which is minikube. Both targets read only the two demo services.
make logs TRACE=e3af3148
make logs AGENT=stock-analyst
make logs-denied
Troubleshooting
Start with the response. Note its status, rule, reason and request_id. Then read the audit records of that request in every pod it touched:
./target/debug/attache logs orders,inventory --context minikube --request <request_id>
HTTP errors from an invocation
These come from POST /v1/agents/{name}/invoke.
| Symptom | Likely cause | What to check |
|---|---|---|
400, rule request.invalid |
The body is not JSON, or it has no input string. |
Send {"input": "...", "context": {...}}. No audit record is written. |
403, rule authz.callers.default |
The charter has no authz.callers rule for this identity. |
For calls from the app, add a rule with identity: local. For a peer, add its SPIFFE id. |
403, rule authz.callers.<service> |
A rule for the identity exists, but it does not list this agent, or its caller_agents does not include the calling agent. A misspelled agent name also gives this error when the rule lists agents by name instead of "*", because authz.callers is checked before the agent lookup. |
agents and caller_agents of that rule. The agent name. |
403, rule guardrail.max_input_bytes |
input plus context is larger than guardrails.max_input_bytes (default 16384). |
The size of input and context. |
403, rule guardrail.input_deny.<name> |
The named regex matched the input or the context. | The regex. The guardrail record shows the input (redacted). |
403, rule guardrail.output_deny.<name> |
The named regex matched the agent’s final output. | The regex. The guardrail record shows the output (redacted). |
403, rule model.refusal |
The model refused the request. | The last model_call record. |
404, rule agent.unknown |
The agent name is not in the loaded charter, and the caller’s authz.callers rule allows "*". |
GET /v1/agents on the sidecar (see below). Did you restart the pod after the charter change? |
429, rule budget.max_calls_per_minute |
Too many invocations of this agent in 60 seconds on this replica. | Budgets. |
429, rule budget.max_cost_usd_per_day |
The agent’s model cost today (UTC) on this replica reached the limit. | cost_usd in the agent’s model_call records. |
429, rule budget.max_depth |
Agent calls are nested more than 4 levels deep. | Orchestrators that call each other in a loop. |
502, rule runtime.unavailable |
The sidecar cannot reach the runtime. | /readyz shows runtime: false. Look for [runtime] lines and agent runtime exited warnings. |
502, rule model.error |
The model API call failed: wrong key, unknown model id, or no network egress. | error in the model_call record. |
502, rule budget.max_steps |
The agent did not finish within max_steps model calls. |
Raise max_steps, or make the task or tools simpler. |
502, rule budget.max_tokens_per_call |
The model hit max_tokens in the middle of a tool call, or with no text. |
Raise max_tokens_per_call (at most 128000). |
The gRPC app API on 127.0.0.1:50001 maps the same cases to status codes. A denial is PERMISSION_DENIED, or RESOURCE_EXHAUSTED for budget.*. An unknown agent is NOT_FOUND. Everything else is UNAVAILABLE. The rule is in the x-attache-rule metadata.
A tool call was denied or failed, but the invocation returned 200
A denied tool call does not fail the invocation. The model gets an error tool result and goes on. Look at steps[] in the response and at the tool_call records. A denial can come from the caller’s sidecar or from the callee’s sidecar. Read both pods’ logs to see which one denied it.
| Rule or text | Likely cause | What to check |
|---|---|---|
guardrail.allowed_tools |
The model called a tool that the agent may not use, or did not declare. | guardrails.allowed_tools and the agent’s tools. The runtime only gets the allowed tools. If the model names another tool, the runtime refuses it itself: you see the step in steps[] (summary not declared), but no tool_call record. |
guardrail.tool_spec |
The call does not match the tool’s declaration: other service, method, agent or URL origin. | The tool entry in the charter. |
guardrail.allowed_services |
The target service is not in guardrails.allowed_services for this agent. |
That list. |
guardrail.path |
The path does not start with /, or it has whitespace, a control character, \, #, %2f, %5c or %00. |
The tool input in the tool_call record. |
authz.services.<name> with DENY |
A deny pattern of that rule matched. | Which pod wrote the first deny record. |
authz.default.deny |
No allow pattern matched, and authz.default is deny. |
On the caller, rules for the target service that apply to identity: local and this agent. On the callee, rules for itself that apply to the caller’s SPIFFE id and agent. |
tool failed: mesh.unavailable: ... |
The peer sidecar could not be reached. | Mesh and TLS. |
tool failed: app.unavailable: ... |
The target app did not answer on 127.0.0.1:<app_port>. |
service.app_port in the target’s charter, and the app container. |
tool failed: http.error: ... |
The http tool request failed. |
Network egress from the pod. |
The tool failed: <rule>: ... text is the tool result that the model gets. You find it in the input of the next model_call record. For these cases, the tool_call record has decision: allow and error set. Its error text does not always name the rule. For a service tool it is the gRPC status text, for example The service is currently unavailable: app unreachable at http://127.0.0.1:8000/....
Pod and startup problems
| Symptom | Likely cause | What to check |
|---|---|---|
The pod stays in ContainerCreating. |
The Secret attache-cert-<svc> or the ConfigMap <svc>-charter does not exist. |
kubectl describe pod. Run the embassy Job, or make charters. |
attached is in CrashLoopBackOff. The log starts with Error: load charter. |
The charter is invalid. The sidecar exits at startup. | kubectl logs --previous -c attached. Run attache charter validate on the file. |
The log says tls.crt identity ... does not match charter identity .... |
service.name or service.namespace in the charter differ from the certificate. |
The mounted Secret. The Job’s --namespace and --services. |
The log says open audit file. |
audit.sink: file, but the path is not writable. |
The volume at the file’s directory, and readOnlyRootFilesystem. |
The pod is Running, but attached is not ready (for example 1/2). |
/readyz returns 503. |
Read the /readyz body (below). |
attached restarts every few minutes. |
The liveness probe runs the same attache-ready check. In the demo Deployments it starts after 20 seconds, runs every 20 seconds, and restarts the container after 6 failures. |
Fix the readiness cause. |
A charter error at startup looks like this:
Error: load charter /etc/attache/charter/charter.yaml
Caused by:
charter is invalid:
- audit.sink: file requires audit.file
What attache-ready checks. attache-ready is a small script in the image. It calls http://127.0.0.1:3500/readyz and exits 0 only on HTTP 200. /readyz returns 200 when both are true:
- the TLS material is loaded (
mesh: true), and - the runtime answered its last
Healthcall (runtime: true).
The charter is always loaded while the sidecar runs, because the sidecar exits when it cannot load it. The script uses port 3500. If you change --http-addr, change the probe too.
Read the /readyz body from inside the pod. The image has python3:
kubectl --context minikube -n attache-demo exec deploy/orders -c attached -- python3 -c '
import urllib.request, urllib.error
try:
print(urllib.request.urlopen("http://127.0.0.1:3500/readyz").read().decode())
except urllib.error.HTTPError as e:
print(e.code, e.read().decode())'
A sidecar that has no TLS files and no runtime answers:
503 {"ready":false,"runtime":false,"model_provider":"unknown","mesh":false}
| Field | Meaning |
|---|---|
ready |
true only when mesh and runtime are both true. |
mesh |
false: no TLS files in --tls-dir. The mesh is off and remote calls fail. The sidecar log says no TLS material in ...: mesh disabled, remote calls will fail. |
runtime |
false: the runtime is not running, or did not answer Health within 3 seconds. |
model_provider |
anthropic or mock, as the runtime reports it. unknown until the runtime has answered once. |
The same python3 command works for /v1/agents (the agents and the tools each may use) and /v1/charter (the loaded charter as JSON).
Runtime crashes
The sidecar supervises the runtime:
- It starts the runtime with
--runtime-cmd(defaultattache-runtime). It appends--listen unix:<socket-dir>/runtime.sock --sidecar unix:<socket-dir>/sidecar.sock. - When the runtime exits, the sidecar marks it not ready and starts it again. The wait starts at 1 second and doubles each time, up to 30 seconds. If the runtime ran for more than 60 seconds, the wait goes back to 1 second.
- If the command cannot start (for example, not found), the sidecar retries with the same growing wait.
- If
--runtime-cmdis empty or cannot be split into words, the sidecar logsinvalid --runtime-cmdand never starts a runtime. The pod never becomes ready. - The sidecar checks
Healthevery 10 seconds while the runtime is healthy, and every second while it is not. - While the runtime is down,
/readyzreturns 503 and invocations fail with502 runtime.unavailable. - With
--no-runtime(orATTACHE_NO_RUNTIME=1), the sidecar starts no runtime. Something else must serveruntime.sock. Readiness still follows itsHealthanswer.
Log lines to look for:
| Line | Meaning |
|---|---|
starting agent runtime |
The sidecar starts the runtime process. |
agent runtime is ready |
The runtime answered Health. The line shows the provider (anthropic or mock). |
agent runtime exited; restarting in ... |
The runtime stopped. Look at the [runtime] lines just before it. |
cannot spawn runtime; retrying in ... |
The command could not start. |
[runtime] report_event failed: ... |
A model_call record did not reach the sidecar. It is missing from the audit log, and its cost is not counted in the budget. |
Mesh and TLS
| Symptom | Likely cause | What to check |
|---|---|---|
A tool fails with mesh.unavailable, and the callee has no record of the call. |
The connection or the TLS handshake failed before the callee handled the call. The peer is down, the name <svc>.<ns>.svc.cluster.local does not resolve, the Service has no port mesh (50002), or the two certificates come from different CAs. |
The peer pod is ready. kubectl get svc <svc> shows port 50002. Both Secrets hold the same ca.crt (command below). |
The callee writes kind authz, rule authz.identity.invalid, caller unknown. |
The client certificate is signed by the trusted CA, but has no spiffe://attache/ URI SAN. |
How that certificate was made. Leaf certificates issued by embassy carry the SAN. |
| Calls to a service in another namespace fail. | Not supported out of the box. Each namespace gets its own CA, because the Job looks up attache-ca in its own namespace. The sidecar also checks the peer certificate for <svc>.<own namespace>.svc.cluster.local. |
Keep services that call each other in one namespace. |
| All mesh calls fail after about one year. | Leaf certificates expire after 365 days. | The expiry date (command in Rotating certificates). |
A tool error says mesh disabled: no TLS material loaded. |
This sidecar started without TLS files. | --tls-dir and the Secret mount. |
Check that two services share one CA:
for s in orders inventory; do
kubectl --context minikube -n attache-demo get secret attache-cert-$s \
-o jsonpath='{.data.ca\.crt}' | shasum -a 256
done
The two hashes must be equal.
Model and provider
| Symptom | Likely cause | What to check |
|---|---|---|
Answers look like Mock answer for: ... or Based on <tool>: .... |
No ANTHROPIC_API_KEY, so the runtime uses the mock model. An agent with model: mock always uses the mock. |
model_provider in /readyz. Create the Secret attache-anthropic and restart the pods. make secret-anthropic does both. |
502 model.error |
The API rejected the call or could not be reached. | error in the model_call record. The key. The model id. Egress to the API. |
Logs
| Symptom | Likely cause | What to check |
|---|---|---|
attache logs prints nothing. |
Wrong namespace (default attache-demo) or context. The pods lack the label attache.io/service. The sink is file. The records are older than the last --tail lines. The filter is too narrow (--agent is an exact match). |
Run the same kubectl logs command by hand. |
error: kubectl logs failed (...) |
kubectl could not read the logs: context, permissions, or no pods match. | kubectl’s own message, printed above it. |
error: usage: attache logs <service> ... |
No service and no --file. |
Add the service name. |
Lines with odd characters such as [2m or [0m. |
ANSI colour codes in sidecar log lines. | These are not audit records. attache logs skips them. |
A model_call record is missing. |
The runtime could not report it. | [runtime] report_event failed lines. |
Changing a charter
The sidecar reads the charter once, at startup. Kubernetes updates the files of a mounted ConfigMap in a running pod after a while, but the sidecar does not read the file again. A change takes effect only when the pod restarts.
- Edit the charter and validate it.
- Update the ConfigMap.
- Restart every pod of the service.
- Check the loaded charter.
./target/debug/attache charter validate examples/charters/inventory.charter.yaml
kubectl -n attache-demo create configmap inventory-charter \
--from-file=charter.yaml=examples/charters/inventory.charter.yaml \
--dry-run=client -o yaml | kubectl --context minikube apply -f -
kubectl --context minikube -n attache-demo rollout restart deployment/inventory
kubectl --context minikube -n attache-demo rollout status deployment/inventory --timeout=300s
For the two demo services, make charters validates both demo charters and updates both ConfigMaps. It does not restart the pods. make deploy updates the ConfigMaps and restarts both Deployments.
Check what a pod runs now:
kubectl --context minikube -n attache-demo exec deploy/inventory -c attached -- \
python3 -c 'import urllib.request; print(urllib.request.urlopen("http://127.0.0.1:3500/v1/charter").read().decode())'
Things to know:
- Restart all pods of the service. Until you do, a pod that restarts on its own (crash, liveness probe, eviction) loads the new charter. The other pods still run the old one.
- An invalid charter does not load. The new pod’s
attachedexits at startup and goes intoCrashLoopBackOff. With the default rolling update and one replica, the old pod keeps serving, androllout statustimes out.kubectl rollout undodoes not help, because the ConfigMap still holds the bad charter. Fix the ConfigMap, then restart again. Always validate before you apply. - Budgets reset on the restart. See Budgets.
- Changing
service.nameorservice.namespacechanges the SPIFFE id. The certificate must match it, or the sidecar does not start. Issue a new certificate, and update theauthzrules in other charters that name the old id.
See Creating agents for what to put in a charter.
Rotating certificates
Certificate rotation is not automatic. These facts matter:
- The Job
embassy-bootstrapissues the certificates. It runsembassy bootstrap --namespace attache-demo --services orders,inventory. - A leaf certificate is valid for 365 days. The CA is valid for 10 years.
- There is one leaf certificate per service. All replicas share the Secret
attache-cert-<svc>. - The sidecar reads the TLS files once, at startup.
- When the Job runs again, it reuses the CA in the Secret
attache-ca(its log saysreusing existing CA). It issues a new leaf for every service in--servicesand updatesattache-cert-<svc>. - The Job issues certificates only for the services in
--services. List every service.
Check when a certificate expires:
kubectl --context minikube -n attache-demo get secret attache-cert-orders \
-o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -enddate
New leaf certificates, same CA
kubectl --context minikube -n attache-demo delete job embassy-bootstrap --ignore-not-found
kubectl --context minikube apply -k deploy/
kubectl --context minikube -n attache-demo wait --for=condition=complete job/embassy-bootstrap --timeout=180s
kubectl --context minikube -n attache-demo logs job/embassy-bootstrap
kubectl --context minikube -n attache-demo rollout restart deployment/orders deployment/inventory
You delete the Job first because a finished Job does not run again. The Job also deletes itself one hour after it finishes (ttlSecondsAfterFinished: 3600). make deploy runs the same steps, except reading the Job log. It also updates the charter ConfigMaps.
Old and new leaf certificates chain to the same CA. So pods keep talking to each other during the rollout.
A new CA
Do this only when you must, for example when the CA key leaked.
kubectl --context minikube -n attache-demo delete secret attache-ca
# then run the same five commands as above
The Job creates a new CA (its log says created new CA) and new leaves. Restart every Deployment in the namespace. Until every pod has restarted, pods with the old CA and pods with the new CA cannot talk over the mesh.
Without Kubernetes
make dev-certs creates a new CA in .local/ca every time it runs (it replaces the old one), and leaf certificates in .local/tls/<svc>. make local-demo issues new leaves on every run and keeps the CA in .local/ca. For one service:
./target/debug/embassy cert-issue --ca .local/ca --namespace attache-demo --service orders --out .local/tls/orders
Budgets on restart and with replicas
Budgets live in the memory of each attached process. They are counted per agent name.
| Budget | How it is counted |
|---|---|
max_calls_per_minute |
A sliding 60-second window. It counts invocations of the agent on this replica that passed authz.callers, the agent lookup and the depth check. A call that a guardrail denies later still counts. Calls from a local orchestrator’s agent tool count too. |
max_cost_usd_per_day |
The sum of cost_usd of the agent’s model_call records on this replica, per UTC day. It is checked once, at the start of an invocation. A running invocation can go over the limit. The mock model costs 0, so this budget never triggers with the mock. |
max_steps |
Model calls per invocation. The runtime enforces it. No state is kept. |
max_tokens_per_call |
max_tokens of each model call. The runtime enforces it. No state is kept. |
| Agent-call depth | More than 4 nested agent calls are denied with budget.max_depth. Fixed, not configurable. |
On restart, all counters go back to zero. This happens on any container restart, pod restart or rollout, including the restart after a charter change.
With replicas, each replica has its own counters. With N replicas, an agent can get up to N times the charter limit in total. Calls from the app go to the sidecar in the same pod. For mesh calls, each sidecar keeps one connection per peer service and reuses it. The Kubernetes Service balances connections, not single calls. So the calls from one pod usually all go to the same replica of the peer, and the load is often not even. To get a total limit, divide it by the number of replicas, and expect some error.
A call to an agent in another pod counts against that agent’s budget in the replica that ran it, not against the caller.
Production checklist (recommendations)
These are recommendations. The repository does not set them up for you, except where a line says “shipped”.
1. Add a NetworkPolicy. No NetworkPolicy is shipped.
- Allow ingress to port 50002 only from pods that run the sidecar.
- Allow the app port only from where it is needed, for example your ingress controller.
- Let apps listen on
127.0.0.1so that only the sidecar can reach them. The inventory example does this. The orders example keeps its port open because it is the demo’s entry point. - Limit egress to DNS, peer pods on 50002, the model API and the hosts of your
httptools. NetworkPolicy works with IP addresses, not host names. For host names you need an egress gateway or a CNI with DNS-based policy. - A NetworkPolicy applies to the whole pod. The runtime runs inside the
attachedcontainer, so it has the same network access as the pod. - Your CNI must enforce NetworkPolicy.
An example for the inventory service:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: inventory-mesh-only
namespace: attache-demo
spec:
podSelector:
matchLabels:
attache.io/service: inventory
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchExpressions:
- { key: attache.io/service, operator: Exists }
ports:
- { port: 50002, protocol: TCP }
2. Set resource requests and limits. Shipped: the attached container requests 50m CPU and 128Mi memory, with a 512Mi memory limit. The app containers have requests only. The sidecar and the Python runtime share the attached container’s limits. If that container is killed for memory, both stop. Measure under real load, then set requests and limits for every container.
3. Harden the securityContext. Shipped on attached only: allowPrivilegeEscalation: false and runAsNonRoot: true. The app containers have none. Consider this for every container:
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
seccompProfile: { type: RuntimeDefault }
With readOnlyRootFilesystem: true, /run/attache is already an emptyDir in the demo Deployments. Mount another one at /var/log/attache if you use the file sink. Test the runtime with these settings before production. The sidecar does not call the Kubernetes API. If your app does not call it either, set automountServiceAccountToken: false on the pod.
4. Ship the audit log. The sidecar writes records. It does not ship them.
- Use your cluster’s log agent (for example Fluent Bit, Vector or Fluentd) to collect the
attachedcontainer. - Keep the lines that start with
{"ts"and parse them as JSON. - Store them append-only, with the retention your rules need. Keep the
*_sha256fields. - Records of one call are spread over several pods. Join them by
request_idortrace_idin your log store. - The sidecar ignores write errors, and the file sink has no rotation. Prefer the stdout sink with a log agent.
5. Manage ANTHROPIC_API_KEY as a secret. Shipped: the Secret attache-anthropic is referenced with optional: true. If it is missing, agents fall back to the mock model without an error.
- Create the Secret from a secret manager, for example External Secrets Operator, Vault, Sealed Secrets or your cloud’s secret store.
- Set
optional: false, so that with a missing Secret theattachedcontainer does not start, instead of falling back to the mock. - The key is an environment variable of the
attachedcontainer. The sidecar and the runtime can read it. The app container cannot. - To rotate the key, update the Secret and restart the pods. Environment variables are read at start.
- Protect the CA too. The CA private key is in the Secret
attache-ca, in the same namespace. Anyone who can read it can issue identities. Limit who cangetSecrets. The shippedembassyRole can get, create and update every Secret in its namespace. You can narrowgetandupdatewithresourceNames. Kubernetes cannot narrowcreatethat way.
6. Pin image tags. Shipped: the images are attache/attached:dev, attache/embassy:dev, attache/orders:dev and attache/inventory:dev. They are built locally into minikube’s Docker, with imagePullPolicy: IfNotPresent. No images are published. For production, build in CI, push to your registry, and use a unique tag or a digest for each build. docker/Dockerfile pins its base images by tag (rust:1.99-bookworm, python:3.12-slim-bookworm, ghcr.io/astral-sh/uv:0.10.12, debian:bookworm-slim). Pin them by digest for repeatable builds.
7. Review charters in CI. There is no CI config in the repo. Validate every charter in every pull request. attache charter validate exits 1 and prints the errors:
# for every charter your repository holds
for f in path/to/charters/*.charter.yaml; do
attache charter validate "$f" || exit 1
done
Require a review for changes to authz and guardrails, for example with a CODEOWNERS file. Keep one committed copy of each charter and create the ConfigMap from it, as make charters does.
8. Alert on denials. There is no metrics endpoint. Build alerts from the audit records in your log store.
| Alert on | Why |
|---|---|
rule: authz.identity.invalid |
A client presented a certificate without a valid identity. |
A rise in decision: deny with authz.callers.* or authz.services.* |
An agent or a peer tries things it may not do. This can be a prompt injection or a wrong charter. |
guardrail.input_deny.* |
Secrets or banned content arrive in inputs. |
budget.max_cost_usd_per_day |
An agent reached its spending limit. |
model_call records with error set |
The model API has problems. |
The sidecar log line agent runtime exited |
The runtime crashes. |
| Container restarts and not-ready pods | Use your normal Kubernetes monitoring. |
9. Other points.
- Every container in the pod counts as
local. Anything in the pod’s network namespace can call127.0.0.1:3500with the identitylocal. Do not add untrusted containers to the pod, and limitpods/execpermissions. - Set budgets per replica (see Budgets).
- Use your own namespace and service names. The Job arguments and
deploy/kustomization.yamlhard-codeattache-demoand the two demo services.
See Microservices for rollout plans for a whole system.
Known limitations
These are true for the current code. “Not built yet” means there is no code for it. CONTRACT.md section 11 has the short list.
- Sidecar injection is manual. There is no mutating webhook. The
attachedcontainer, the charter mount and the TLS mount are written into each Deployment. Theattache.io/injectannotation in the demo Deployments has no effect. - Certificates are per service, not per pod. All replicas share one Secret. A one-shot bootstrap Job issues them. There is no rotation. Per-pod certificates and
embassyas a running CA server are not built yet. - One CA per namespace. The bootstrap Job looks up the CA Secret
attache-cain its own namespace (the one in--namespace, where it also writes the certificates). So each namespace gets its own CA. Mesh calls across namespaces are not supported out of the box. - Budgets are in memory per replica. They are not shared between replicas, and they reset on restart.
- The runtime calls the model API directly. The sidecar does not proxy model calls. It only writes the
model_callrecords the runtime reports. The sidecar cannot block a single model call. If the runtime fails to report a call, the record and its cost are missing. - The runtime runs in the sidecar’s container. It is a child process of
attached. It shares the container’s filesystem, environment (includingANTHROPIC_API_KEY), resource limits and network. pii_redacthas a narrow scope. It applies to the agent’s final output and to audit records. It does not apply to prompts and tool results sent to the model provider. With a real model, data such as the demo’s warehouse email reaches the provider. It finds only emails and phone numbers.- Configuration is read once. The charter, the TLS files and
ANTHROPIC_API_KEYare read at startup. A ConfigMap or Secret change needs a pod restart. - Refusal fallbacks are not enabled. A model refusal is returned as
model.refusal. - No NetworkPolicy is shipped.
- No UI. The charter schema (
schemas/charter.schema.json) is the API. - No CI config in the repo.
- Audit output is basic. There are two sinks (stdout and file). There is no shipping, no file rotation, and write errors are ignored.
- No metrics and no trace export. There is no metrics endpoint. Trace ids are propagated, but spans are not exported to a tracing backend. They exist only in the audit records.
- The manifests are a demo. The namespace
attache-demoand the servicesorders,inventoryare hard-coded in the Job and the kustomization. The images are:devtags built inside minikube. No images are published. - The readiness probe uses a fixed address.
attache-readyalways calls127.0.0.1:3500.
Fixed limits and timeouts in the code:
| Limit | Value |
|---|---|
Audit payload size (input and output, each) |
8 KiB, then truncated |
Values remembered from audit.redact paths |
15 minutes per request, values of 4 or more characters |
| Agent-call nesting | 4 levels (budget.max_depth) |
max_tokens_per_call |
1 to 128000 |
guardrails.max_input_bytes default |
16384 |
| Sidecar to runtime request timeout | 300 seconds |
| Mesh connect timeout and request timeout | 5 seconds and 120 seconds |
Sidecar HTTP client (app and http tools) timeout |
30 seconds |
| Runtime restart wait | 1 second, doubling up to 30 seconds |
| Leaf certificate and CA lifetime | 365 days and 10 years |