Section B · Technical Core

Governance & Audit

Observability for agent systems — structured logs, distributed traces through tool calls, OpenTelemetry, PII redaction. Safety guardrails — kill switches, rate caps, allow-lists, breakglass approval.

Why this is infrastructure's problem

The Agent Systems team decides what the agent does. You decide what gets recorded, who can stop it, and what bounds it stays inside. In a high-stakes fintech, that boundary is the difference between "shippable" and "shouldn't have shipped":

  • Regulators ask "why did your AI do X?" — you produce the trace.
  • Security asks "could an agent action escape its scope?" — you point at the allow-list and the audit log.
  • Ops asks "an agent is in a retry storm against a partner, how do I stop it?" — you point at the kill switch.
  • Privacy asks "do you handle PII in logs correctly?" — you point at the redaction layer.

This is exactly what the JD means by "Define guardrails, observability, and failure handling for agent-driven workflows."

Structured logs

Plain text logs are useless at scale. Use JSON-line logs with consistent fields. In Rust, the tracing crate is the standard.

use tracing::{info, instrument};

#[instrument(skip(req), fields(run_id = %req.run_id, model = %req.model))]
pub async fn handle(req: GenerateReq) -> Result<Resp, Error> {
    info!(input_tokens = req.tokens.len(), "request received");
    let resp = backend.call(&req).await?;
    info!(output_tokens = resp.tokens.len(), cost_usd = ?resp.cost, "request complete");
    Ok(resp)
}

Logs to ship:

  • Per request: request_id, run_id, user_id, model, input_tokens, output_tokens, cost, latency, status.
  • Per tool call: run_id, tool_name, idem_key, args_hash, result_hash, status, latency.
  • Per approval: run_id, tool_name, approver, decision, latency-to-approve.
  • Per error: error_kind, source, retryable, full chain via tracing-error.

Don't log full prompts and responses by default — they're large, PII-laden, and expensive to store hot. Log hashes and store the bodies in cheaper content-addressed storage.

Trace IDs propagated through tool calls

A distributed trace is a tree of spans. Each span is one operation (HTTP call, function, tool invocation). Spans share a trace ID and reference their parent span ID. Result: you can reconstruct the entire path of a request, across services.

For an agent run:

trace(run_id=R1)
├── span: orchestrator.run
│   ├── span: model.call (step 1)
│   ├── span: tool.dispatch.search_kyc
│   │   └── span: kyc_service.lookup
│   ├── span: model.call (step 2)
│   ├── span: tool.dispatch.draft_summary
│   └── span: model.call (step 3)

Propagation rules:

  • HTTP/gRPC requests carry trace context in headers (traceparent, tracestate). tower/tonic with the OTel middleware do this automatically.
  • Async tasks inside a process — use #[instrument] or .instrument(span) to keep the span context across spawn boundaries.
  • Background jobs and queues — propagate trace context in the message envelope. Easy to forget.
  • Don't generate a fresh trace ID per step — the whole agent run shares one trace ID.

OpenTelemetry in Rust

OTel is the cross-language standard. In Rust, tracing + tracing-opentelemetry bridges to OTLP exporters (Jaeger, Tempo, Honeycomb, Datadog, etc).

use opentelemetry::{global, KeyValue};
use opentelemetry_otlp::WithExportConfig;
use tracing_subscriber::{prelude::*, EnvFilter};

fn init_tracing() {
    let tracer = opentelemetry_otlp::new_pipeline()
        .tracing()
        .with_exporter(
            opentelemetry_otlp::new_exporter()
                .tonic()
                .with_endpoint("http://otel-collector:4317")
        )
        .install_batch(opentelemetry_sdk::runtime::Tokio)
        .unwrap();

    let otel = tracing_opentelemetry::layer().with_tracer(tracer);
    let fmt = tracing_subscriber::fmt::layer().json();

    tracing_subscriber::registry()
        .with(EnvFilter::from_default_env())
        .with(otel)
        .with(fmt)
        .init();
}

Metrics use opentelemetry-prometheus or push directly to OTLP. Standard metrics for an inference gateway:

  • requests_total{model, status} (counter)
  • request_duration_seconds{model, status} (histogram, for p50/p99)
  • tokens_total{model, kind="input|output"} (counter)
  • cost_usd_total{model, tenant} (counter)
  • concurrent_inflight{backend} (gauge)
  • circuit_breaker_state{backend} (gauge: 0=closed, 1=open, 2=half-open)

PII redaction

Two flavors of PII risk in agent infra:

  1. In logs and traces — full prompts may contain customer names, account numbers, identifiers. Don't ship those raw to a third-party APM.
  2. In prompts sent to external model providers — depending on contract and jurisdiction, you may need to redact or tokenize before the call.

Patterns:

  • Tokenize at the boundary. Replace account_id=12345 with account_id=ACCT_T0a8f. Keep the mapping in a separate, access-controlled store. Logs reference tokens, not raw values.
  • Redact known patterns. Regex for emails, SSNs, card numbers; replace with [REDACTED].
  • Hash, not log. Log prompt_hash=sha256(prompt); store the prompt body in an access-controlled content store keyed by hash.
  • Per-tenant data plane. EU customer prompts only go to EU-resident models when contractually required.
The leakage corner case

Even hashed prompts can leak. If your prompt template includes the customer's name in a predictable place, two requests for the same customer hash to the same value. Salt per-tenant; or only hash for diagnostics, not as a primary identifier.

Audit logs for autonomous actions

An audit log is different from regular service logs: append-only, retention-bound, tamper-evident, indexed for legal/regulatory queries. For every agent run, you record:

  • Run start: run_id, user, model versions, prompt versions, tool catalog version, policy version.
  • Each model call: prompt hash, response hash, model+params, latency, cost.
  • Each tool dispatch: tool, args hash, idem key, outcome hash, approver (if applicable).
  • Each approval: approver, timestamp, decision, what they saw.
  • Run end: final outcome, total cost, total steps.

Storage:

  • Append-only Postgres table (write-only role) or Kafka topic with infinite retention for the audit window.
  • Hash-chain events for tamper evidence: each event includes a hash of the previous event.
  • Cold archive to S3 with object-lock for the regulatory retention period.
  • Searchable index (OpenSearch / BigQuery) — denormalized for fast "give me all runs for user X on date Y."

Safety guardrails — the kit

Guardrails are the things that bound an agent's behavior independent of what it "wants" to do. The kit:

GuardrailEffect
Kill switchHalt all agent runs immediately (global or per-policy).
Rate capsPer-user, per-tenant, per-tool, per-policy. Stops runaway agents.
Action allow-listPer-policy enumerated set of tools that may be called.
Cost budgetPer-run, per-tenant, per-day. Hard cap.
Step limitPer-run max steps. Prevents loops.
DeadlineWall-clock max per run.
Risk-tier approvalHigh-tier actions require human approval before dispatch.
Anomaly rate-limitIf tool-call rate deviates >Nσ from baseline, pause and alert.

Kill switches

The simplest, most important guardrail. A flag in a fast-readable store (Redis, a config service) that every executor checks before each step.

pub struct KillSwitch {
    client: redis::aio::ConnectionManager,
    cache: std::sync::Arc<arc_swap::ArcSwap<KillState>>,
}

impl KillSwitch {
    // Polled in the background, latest state cached locally for cheap reads.
    pub fn is_killed(&self, policy: &str) -> bool {
        self.cache.load().policies.contains(policy)
            || self.cache.load().global
    }
}

// In the executor:
if kill.is_killed(&run.policy) {
    halt_run(&run, "kill_switch").await?;
    return;
}

Properties to verify in design discussion:

  • Activatable in seconds. Not "redeploy with a feature flag." A button somewhere.
  • Per-policy granularity. Killing the SAR-draft agent shouldn't kill the alert-triage agent.
  • Audited. Who pushed the button, when, why.
  • Discoverable. On-call should know it exists and how to use it.
  • Cached locally so a Redis outage doesn't break the kill check (it should fail closed = act-as-killed, or fail open = act-as-allowed; the safer default depends on context).

Rate caps

Token-bucket per dimension. See chapter 11 for the implementation. Dimensions you want for agent infra:

  • Per user. One user can't burn the whole tenant's budget.
  • Per tenant. One tenant can't burn the fleet.
  • Per tool. A flaky external tool can't be hammered.
  • Per (run, tool). A single run can't call the same tool 1000 times.
  • Per model. Expensive models get tighter caps.

Action allow-lists

Per-policy, the agent has access to a named set of tools and no others. The orchestrator enforces this at dispatch time — never at prompt-construction time only. Defense in depth: even if a model is prompt-injected into requesting a forbidden tool, the dispatcher refuses.

pub struct Policy {
    pub id: String,
    pub tools_allowed: std::collections::HashSet<String>,
    pub risk_tier: RiskTier,
    pub max_steps: u32,
    pub max_cost_usd: f32,
}

fn authorize_tool(policy: &Policy, tool: &str) -> Result<(), AuthError> {
    if policy.tools_allowed.contains(tool) {
        Ok(())
    } else {
        Err(AuthError::ToolNotAllowed(tool.into()))
    }
}

Human-approval breakglass

"Breakglass" comes from physical-security parlance: a sealed override available only in emergency. In agent infra:

  • High-risk tools require human approval in the normal flow.
  • Some workflows need an "emergency override" — e.g., security incident triage where you accept a higher-risk action under sign-off.
  • Breakglass is audited heavily. Multiple approvers, justification text, automatic retroactive review.

Architecturally: the approval queue is its own service. Tools that require approval publish a request; an operator UI displays it; the operator approves/denies; the executor receives the decision and continues. Timeouts on approval (e.g., 30 min) result in a default-deny.

Interview synthesis

"For high-tier autonomous actions, the architecture is: allow-list at dispatch, idempotency key on the call, approval gate before execution, audit event on intent and outcome, kill switch over the whole policy. Defense in depth — no single failure of any one of those lets a bad action through."

✓ Check yourself

Answer from memory before opening each one.

  1. An agent is in a retry storm against a partner API at 3am. What must already exist for on-call to stop it, and what properties should it have?

    Show answer

    A kill switch that every executor checks before each step. It must be activatable in seconds without a redeploy, scoped per policy so one agent can be halted alone, audited for who pressed it and why, and known to on-call. See Kill switches.

  2. The Redis instance holding the kill flag goes down. What does an executor do, and how do you decide between fail closed and fail open?

    Show answer

    The executor reads a locally cached copy so the check keeps working, and for a lost flag it either acts as killed (fail closed) or as allowed (fail open). Choose by which error costs more: for money-moving agents, halting is usually safer, while for a low-risk read-only agent, availability may win. See Kill switches.

  3. In your tracing backend each step of one agent run appears as a separate trace. What went wrong, and where is the usual miss?

    Show answer

    Someone created a fresh trace ID per step, or the context was lost at a boundary. A run shares one trace ID: propagate traceparent on HTTP and gRPC, keep span context across spawn with instrument, and carry it in the message envelope for queues and background jobs, the easy one to forget. See Trace IDs propagated through tool calls.

  4. A colleague wants full prompts in the logs for easier debugging. What do you propose instead, and what weakness remains in the hashed approach?

    Show answer

    Log a hash and store the body in an access-controlled content store keyed by it, since full prompts are large, may contain PII and are costly to keep hot. A hash of a predictable template still matches across requests for the same customer, so salt per tenant and use it for diagnosis, not as an identity. See Structured logs and PII redaction.

  5. The allow-list is checked when the prompt is built, and a prompt injection persuades the model to request a forbidden tool. What happens, and what is the fix?

    Show answer

    The model's request reaches the dispatcher unchecked and the tool runs. Enforce the allow-list at dispatch time, per policy, so the refusal does not depend on the model behaving. Prompt-time filtering is a convenience, not a control. See Action allow-lists.

  6. An investigator claims an audit event was deleted after the fact. How should the audit store be built to expose or prevent that?

    Show answer

    Use an append-only table through a write-only role, hash-chain events so each holds the hash of the previous one, and archive to object-locked storage for the retention period. A missing or edited event breaks the chain. See Audit logs for autonomous actions.

Takeaway

  • Agent Systems decides what the agent does; infrastructure decides what is recorded, who can stop it and what bounds it.
  • Use structured JSON logs through tracing, ship ids and hashes instead of full prompts, and store bodies in an access-controlled content store.
  • One trace per agent run: propagate context through headers, spawn boundaries and queue envelopes, with a span per model call and tool dispatch.
  • Redact or tokenise PII at the boundary, salt hashes per tenant, and keep data in the region a contract requires.
  • Audit logs are append-only, tamper-evident through hash chaining, retained and indexed for the questions a regulator will ask.
  • Layer guardrails: allow-list enforced at dispatch, idempotency key, approval gate, audit on intent and outcome, caps on rate, cost and steps, and a kill switch over the policy.

Governance covers the platform around the code; the coding rounds come next, starting with the Rust you must write fluently. → Coding fundamentals

Further reading

  • Trace Context — W3C Recommendation, 2021. The traceparent and tracestate headers that carry context across services.
  • Traces — OpenTelemetry docs. Spans, context and how parent-child links build a trace.
  • OpenTelemetry Rust — OpenTelemetry docs. The state of the Rust SDK and exporters.
  • tracing — docs.rs. Spans, events and instrumenting async code.
  • OWASP Top 10 for LLM Applications — OWASP GenAI Security Project, 2025. Risks including prompt injection and excessive agency, and mitigations.
  • AI Risk Management Framework — NIST, 2023. A reference for governance and risk-tiering language.