Point and Call: Why Your AI Agent Forgets What You Told It Not To Do

AI agents remember what you told them to do. Over a long task, they forget what you told them not to do.

In February, a director of AI alignment at a large technology company gave her agent a narrow job: look through her inbox and suggest emails to delete. She added a condition in plain words — don’t action until I tell you to.

It deleted more than two hundred.

By her account, the agent had summarized the conversation to save context space, and the summary dropped her instruction. Not overrode it. Dropped it. At the moment of deletion the agent was not defying a constraint; as far as its working context was concerned, there was no constraint to defy.

That distinction is the whole problem. We spend our safety effort on whether a model will comply with a rule, and the more common failure is that the rule is no longer in the room.

The asymmetry is measured, not anecdotal

This is not one unlucky inbox. One 2026 study found that a widely used coding agent’s summarizer kept 53% of safety rules after a single pass, and 10% after five. Five summarization passes is not an unusual day for an agent working a long task.

Another study, across 12 models, found that even simple “never” rules faded as sessions grew while “always” rules held.

The explanation offered for that asymmetry is uncomfortable once you see it, because it is structural rather than a model defect. An “always” rule shows up in every response the agent produces, so the agent’s own output history keeps reminding it the rule exists. A “never” rule produces nothing. It leaves no trace in the transcript, so when the transcript gets compressed there is nothing to compress — the rule was only ever stated once, at the start, in a span that summarization treats as preamble.

Your “always sign off with the ticket number” rule is self-reinforcing. Your “never delete anything without approval” rule is a single sentence decaying in a buffer.

Japanese railways solved this with pointing

Train drivers in Japan practise shisa kanko — pointing at each signal and saying aloud what it shows. “Signal green.” They point at the speedometer and call the speed. It looks absurd to outsiders and it is one of the more effective safety interventions in transport.

A railway research institute found it cut mistakes on a simple task by almost 85%.

The mechanism is not discipline and it is not ritual. A silent check leaves no evidence that it happened, to anyone, including the person doing it. Pointing and calling converts a check into an action with a motor component and an audible output, which means it can be observed, audited, and noticed when it goes missing.

That last one is the part that transfers to agents. The value of the call-out is not that the agent says the rule. It is that a missing call-out is detectable.

The card lives outside the conversation

Four components, and the first one is the one teams get wrong.

A rule card stored as a signed, versioned artifact — in our case an OCI artifact pulled by digest, delivered to the workload as a read-only mount. The card is not in the system prompt as a string literal in application code, and it is definitely not in the conversation, because anything in the conversation is subject to the compression that caused the problem.

A context assembler that attaches the card verbatim at every boundary where context is rebuilt. An enforcement point at the tool gateway — the same gateway that prices exposure if you are running the margin pattern. And a call-out verifier that runs between the call-out and the side effect.

The card is bound to a workload identity. The gateway resolves which card applies from the caller’s SPIFFE SVID rather than from a parameter, so an agent cannot present a more permissive card than the one it was issued.

1. Put the rules on a card

Keep standing rules, above all the “nevers,” in a short list outside the conversation. Attach it word for word to every fresh context, after each summarization, and in each brief to a sub-agent.

Researchers call this constraint pinning. In one study it took violations from 30% to 0%.

The card:

card: support-agent-nevers
version: 11
digest: sha256:2f91...cc40
issued_by: platform-risk
max_rules: 12
rules:
- id: N1
text: "Nothing is deleted until the user approves it in this session."
enforcement: code
applies_to: [mail.delete, object.delete]
- id: N2
text: "Never promise a delivery date."
enforcement: call_out
applies_to: [email.send, ticket.reply]
- id: N3
text: "Never send to an address outside the customer's own domain."
enforcement: code
applies_to: [email.send]
- id: N4
text: "Never close a ticket you opened in this session."
enforcement: code
applies_to: [ticket.close]
removal_policy:
requires: original_setter

Note enforcement. Each rule declares whether a machine can check it or whether it needs the agent to state its reasoning. That field is what stops the card becoming a wish list.

The injection points are where this fails in practice, because there are more of them than anyone lists on a whiteboard. Fresh context. After compaction. Sub-agent spawn. Retry after a tool error. Model fallback when the primary is rate-limited. Any truncation of a long tool result that happens to sit above the card in the buffer.

We instrument every one and emit a single metric: did the card digest appear in the final assembled context for this turn? Card survival rate is the first thing to measure and almost nobody measures it.

2. Point and call before anything hard to undo

Before any action that is hard to undo — delete, send, pay, publish — show the agent the card and have it call out which rule applies and where things stand.

“Rule: nothing is deleted until you approve. Status: not approved. Asking now.”

Make the call-out a required structured parameter of the tool call, not prose in the transcript:

{
"tool": "mail.delete",
"args": { "message_ids": ["...", "..."], "count": 212 },
"call_out": {
"card_digest": "sha256:2f91...cc40",
"rules_cited": ["N1"],
"rule_text_verbatim": "Nothing is deleted until the user approves it in this session.",
"status": "not_approved",
"basis": "No approval message from the user in this session.",
"intent": "ask"
}
}

Prose in the transcript cannot be enforced on, verified, counted, or alerted on. A required parameter can be all four. This is the implementation decision that matters most in the whole pattern, and it is a schema change rather than a model change.

rule_text_verbatim has to match the card by string comparison. The agent reads the rule off the card and copies it; it does not paraphrase from memory. Each retelling of a rule shifts it slightly, and “nothing is deleted until approved” becomes “deletions need approval” becomes “significant deletions need approval” over a long enough session.

3. No call-out, no action

The system blocks a hard-to-undo action that arrives without a call-out. Not logs it. Blocks it.

New York subway conductors must point at a striped board on the platform before opening the doors. If they cannot see the board, the doors stay shut. The rule does not care why the board is not visible.

The gateway check in Go:

func (g *Gateway) Admit(ctx context.Context, req ToolCall) error {
card, err := g.cards.ForIdentity(ctx, req.Principal) // from SPIFFE SVID
if err != nil {
return fmt.Errorf("no card resolved: %w", err) // fail closed
}
rules := card.RulesFor(req.Tool)
if len(rules) == 0 || !req.Tool.HardToUndo() {
return nil
}
co := req.CallOut
switch {
case co == nil:
return ErrNoCallOut
case co.CardDigest != card.Digest:
return ErrStaleCard // card moved mid-run
case !co.Cites(rules.IDs()):
return ErrRuleNotCited
case !card.Rule(co.RulesCited[0]).MatchesVerbatim(co.RuleTextVerbatim):
return ErrRuleParaphrased
}
return g.verifier.Check(ctx, card, req)
}

ErrStaleCard earns its place. If the card is updated mid-run, an agent carrying the old digest is operating on rules that no longer apply, and the correct behaviour is to stop and reassemble rather than proceed on a stale constraint set.

Fail closed when no card resolves. An agent whose card lookup failed is an agent with no constraints, which is the state you are trying to make impossible.

4. Check the call-out against the card

A second reader — a small model or a person — compares the call-out with the card and the action. A stated claim is far easier to check than a silent decision.

Split the check by what kind of thing it is. The mechanical part is policy:

package agents.callout
default allow := false
# The agent claims approval exists. Verify against the approval
# store, not against the agent's own assertion.
allow if {
input.call_out.status == "approved"
some a in input.session.approvals
a.scope == input.tool
a.covers_count >= input.args.count
a.actor_kind == "human"
}
# Asking is always permitted; asking is not acting.
allow if {
input.call_out.intent == "ask"
not input.performs_side_effect
}
violation["status_claim_unsupported"] if {
input.call_out.status == "approved"
count(input.session.approvals) == 0
}

The claim “status: not approved” is checkable. So check it, against the approval store, every time. A call-out whose factual claims are verified mechanically and whose judgment claims go to a reviewer is a much stronger control than one that is read by a model and nodded at.

For the judgment rules — “never promise a delivery date” — give the verifier the card, the action, and the call-out. Nothing else. Do not hand it the conversation. Whatever framing in that conversation caused the primary agent to drift will do the same to a reviewer reading the same text, and you will have built two agents with one blind spot.

5. Guard the card

Keep it short. The longer the card, the less each rule counts — attention is finite for models the same way it is for the conductor who has nine things to point at and two seconds to do it.

We cap cards at twelve rules and enforce the cap in CI. The cap is the useful part, because it forces the argument about which rule to drop to happen in a pull request rather than never.

Only the person who set a rule can take it off. That is the removal_policy field, and it should be enforced by the artifact pipeline, not by convention.

Enforce in code what code can check

This is the line that decides whether you have built a control or a performance.

“Never delete without approval” is a code rule. The gateway can check the approval store. Putting it on the card as a call-out rule and trusting the agent to honour it is choosing a weaker mechanism when a stronger one was available.

“Never promise a delivery date” cannot be checked by code in any way I would defend. That is what the call-out is for.

So the card should get smaller over time, not bigger. Every rule that moves from enforcement: call_out to enforcement: code is a rule that no longer depends on the model remembering anything. The card is for the residue — the rules that genuinely need judgment.

Count the missing call-outs

Your existing monitoring shows “always” rules being followed, because following them produces output. It says nothing about the “nevers,” because a respected “never” produces silence and so does a forgotten one.

A skipped call-out is the signal you get. It is the only one.

Three metrics, and they are cheap:

Card survival rate — the share of assembled contexts containing the current card digest. Missing call-out rate — hard-to-undo tool calls rejected for ErrNoCallOut, by agent and by boundary type. Paraphrase rate — ErrRuleParaphrased rejections, which spike right before the card stops working at all.

Alert on card survival dropping, not violations rising. Violations are the lagging indicator.

Where this pattern breaks

Call-out theater

The serious failure. A model that is rewarded for producing well-formed call-outs produces well-formed call-outs, including when its actual reasoning does not match. A self-report is not evidence of an internal state. This is why the verifier has to check factual claims against external systems and why the call-out must never be the only control on anything a machine could have checked.

Card bloat

Every incident review wants to add a line, every legal review wants two. A card with forty rules is a card the model skims and a card nobody reads before adding the forty-first. The cap is not bureaucracy; it is the mechanism.

Incomplete injection points

The team instruments fresh context and compaction, ships, and six weeks later discovers that the retry-after-error path rebuilds context from a different assembler which never had the card. Card survival rate catches this. Nothing else does.

Latency on the hot path

Call-out plus verification adds a round trip before every irreversible action, and the temptation is to sample. Do not sample. Narrow the set of tools classified hard-to-undo instead, which is a decision you can defend, unlike a 10% check rate.

Verifier drift

The verifier has its own prompt, its own model version, and no tests. Give it a regression suite of recorded call-outs with known verdicts and run it in CI, because a verifier that silently started approving everything looks exactly like one that works.

Adoption: measure survival before you block anything

If agents are already running in production, the first move is not enforcement.

Write one card for one agent, five rules maximum, and attach it at the context boundaries you know about. Log everything, block nothing. Then instrument the boundaries and watch card survival for a fortnight — the number will be below 100% and the gap will point at a code path you did not know existed.

Next, require call-outs on a shortlist of hard-to-undo tools, still without blocking. The missing call-out rate here tells you how much of your agent’s behaviour was running on unstated assumptions.

Then block, one tool at a time. Then add the verifier. Then start moving rules from call_out to code and shrinking the card.

Teams that start at “block” generate enough false rejections in week one that the pattern gets rolled back before anyone sees the survival data — which was the actual finding.

Supervision evidence is a record that the control fired

Under SEC and CFTC oversight, and under Regulation SCI, the questions that follow an incident are about the control at the moment of the action — not the control as it exists today, and not the policy document describing it.

The call-out log answers that directly. For each hard-to-undo action it holds the card digest in force, the rule cited, the verbatim text as the agent read it, the claimed status, the verifier’s decision, and the identity of the workload. That is a stronger artifact than a transcript in which somebody appears to have been careful.

Here is the part I did not expect when we started. The call-out log is also the cleanest record we have of the rules we never wrote down. Reading rejected call-outs, you find agents citing constraints that exist nowhere on any card — picked up from a prompt, an example, a document in the corpus. Those are the rules your organization is actually operating on, and until you see them cited you have no idea they are there.

Rules you cannot see being followed are rules you cannot trust

The pattern is five steps and one idea: a constraint that produces no observable behaviour when it is working is a constraint you cannot operate. Make the check audible. Then count the silences.

The director in February did not get it wrong. She stated a clear condition in plain language, and the system she was using had no mechanism to carry that condition across a boundary it crossed on its own initiative. That is an architecture problem, and the two hundred deleted emails were the architecture reporting its state.

So: which “never” would hurt most if your AI forgot it halfway through a long task?

The Lot Number Pattern: Provenance for AI Output

Go look at the painkillers in your medicine cabinet. Somewhere on the box, in small print next to the expiry date, there is a lot number.

Nobody reads it. It does not help you take the pill. It exists for one day — the day a manufacturer discovers a bad batch of an ingredient and needs to know which boxes used it. Not every box. Those boxes. The lot number is what makes the difference between pulling one production run and pulling a product line.

Now picture that day with AI.

Your assistant has been quoting an old refund policy for three weeks. Someone notices. Fixing the document takes ten minutes. Then a lawyer asks which customers got the wrong answer, and the room goes quiet, because nobody can say. Not “it will take a while” — nobody can say, at any cost, with any amount of effort.

Most teams keep logs. Logs are sorted by time. The documents have changed since. Few systems record which version of which document each answer actually read.

So the ten-minute fix is the easy part, and everything after it is a guess.

Logs are sorted by time, and a recall asks a different question

This is the whole gap in one sentence. Logs answer “what happened at 3:02pm?” A recall asks “which outputs used this document?”

Those are different access patterns, and you cannot serve the second one from a store built for the first — not at volume, not under time pressure, not with the retrieval corpus mutating underneath you.

The liability is not theoretical. In 2024 a Canadian tribunal held an airline liable for wrong fare information its chatbot gave a customer. Companies answer for what their AI says. The practical shape of that answerability is: when you get something wrong, can you identify who you got it wrong to?

The provenance store sits on the inference path

Three pieces. A provenance store with its own retention and access rules. A stamping interceptor in the inference path, inside the request boundary rather than bolted on afterward. And an ingredient index — the inverted mapping from ingredient to outputs.

The stamp is written synchronously before the output is returned to the caller. This is the part teams resist, because it adds latency to the hot path. Write it anyway. A stamp cannot be reconstructed after the fact: the retrieval set is gone, the prompt has been rebuilt, the model alias has moved. Provenance you intended to record asynchronously is provenance you do not have.

What you can do asynchronously is everything bulky — index maintenance, forward-trace enrichment, signing, export to long-term storage. Keep the hot path to one append of a few kilobytes.

If you have done software supply chain work this will look familiar, and that is not an accident. A lot record is an in-toto style attestation for an inference: materials in, product out, with a digest on both sides. We sign lot records the same way we sign build provenance, which means nobody can quietly backdate one after an incident.

1. Stamp every output with what went into it

The lot number records what went in: model version, instructions version, the exact documents and data read, the tools called.

A lot record:

{
"lot_id": "lot_01HQJ8M2X7",
"produced_at": "2026-10-09T14:21:08.442Z",
"producer": "spiffe://platform.internal/ns/support/sa/answer-service",
"output_digest": "sha256:1f0c...b4e2",
"ingredients": {
"model": { "id": "vendor-model-4", "version": "2026-07-11", "params_digest": "sha256:aa31...90f7" },
"instructions": { "ref": "prompts/support-answer", "version": "v38", "digest": "sha256:77be...c104" },
"documents": [
{ "ref": "kb/refund-policy", "version": "r214", "digest": "sha256:9d2a...1c8b", "chunks": [3, 4] },
{ "ref": "kb/shipping-exceptions", "version": "r88", "digest": "sha256:04ff...77aa", "chunks": [11] }
],
"data": [
{ "ref": "crm.account", "key_digest": "sha256:c3d1...8e42", "as_of": "2026-10-09T14:20:51Z" }
],
"tools": [
{ "name": "order.lookup", "call_digest": "sha256:5b70...2f19", "result_digest": "sha256:e9a4...6630" }
],
"retrieval": { "index_version": "emb-2026-08-02", "query_digest": "sha256:31cc...70d5", "k": 8 }
},
"derived_from": [],
"signature": { "scheme": "sigstore", "bundle_ref": "prov://lots/lot_01HQJ8M2X7.sig" }
}

Every ingredient is identified by digest, not by path. A path is a pointer to whatever lives there now, and the entire reason you are building this is that the thing living there now is different from what the model read.

Stamping in Python, as a context manager around the inference call:

@contextmanager
def lot(producer: str, task: str):
rec = LotRecord(lot_id=new_lot_id(), producer=producer, task=task)
token = _current_lot.set(rec) # contextvar, so retrieval
try: # and tool layers can append
yield rec
finally:
_current_lot.reset(token)
rec.freeze() # no appends after this point
provenance.put_sync(rec) # blocking, before output returns
index.enqueue(rec) # inverted index, async
# Retrieval and tool wrappers append without the caller thinking about it.
def record_document(ref: str, version: str, digest: str, chunks: list[int]):
if (rec := _current_lot.get(None)) is not None:
rec.ingredients.documents.append(
Document(ref=ref, version=version, digest=digest, chunks=chunks))

The contextvar matters more than it looks. If stamping requires every call site to remember to pass a record through, stamping will be incomplete within a month — the exception path, the cache hit, the retry, the fallback model. Put collection in the layers that already touch the ingredients and the call sites cannot forget.

Freeze before write. An unfrozen record invites a well-meaning engineer to append to it later, and a lot record that can change is not evidence.

2. Trace forward to where the output landed

A stamp tells you what went into an answer. A recall needs to know where that answer went: who received it, and what it became.

An email. A closed ticket. A figure in a quarterly report. A merged code change. Each of those is a different notification problem and a different correction problem, so record which one it was.

{
"lot_id": "lot_01HQJ8M2X7",
"deliveries": [
{ "channel": "email", "recipient_digest": "sha256:b8e1...0c73",
"sent_at": "2026-10-09T14:21:12Z", "external": true },
{ "channel": "ticket", "ref": "CS-44192", "state": "closed",
"closed_at": "2026-10-09T14:26:03Z" }
]
}

Recipients are stored as digests with a reference to where the identity actually lives. The provenance store should be able to tell you that an output reached a person without itself becoming a second copy of your customer database.

Forward trace is the half that gets dropped. Teams build stamping, feel finished, then discover during their first incident that they can list five hundred affected outputs and cannot tell who saw any of them. Affected outputs with no recipients is a list you cannot act on.

3. Index by ingredient, not by timestamp

Store the links in both directions so that the recall question takes seconds instead of a weekend of log archaeology.

The edge table is the entire trick:

CREATE TABLE lot_ingredient (
ingredient_ref text NOT NULL, -- 'kb/refund-policy'
ingredient_version text NOT NULL, -- 'r214'
ingredient_digest bytea NOT NULL,
lot_id text NOT NULL REFERENCES lot(lot_id),
kind text NOT NULL, -- document | model | instructions | tool | data
PRIMARY KEY (ingredient_ref, ingredient_version, lot_id)
);
CREATE INDEX lot_ingredient_recall
ON lot_ingredient (ingredient_ref, ingredient_version)
INCLUDE (lot_id, kind);

And the recall query, which should return in milliseconds:

SELECT l.lot_id, l.produced_at, d.channel, d.recipient_digest, d.ref
FROM lot_ingredient li
JOIN lot l ON l.lot_id = li.lot_id
LEFT JOIN delivery d ON d.lot_id = li.lot_id
WHERE li.ingredient_ref = 'kb/refund-policy'
AND li.ingredient_version IN ('r211','r212','r213','r214')
AND l.produced_at >= '2026-09-18';

Nothing clever. That is the point — this is an ordinary secondary index on a question nobody thought to ask at design time. The cost of adding it later, after a year of outputs, is a backfill you cannot run because the data was never captured.

4. Recall is a workflow with states, not an email

When an ingredient turns out to be wrong, pull every output that used it, correct what you control, tell the people who relied on the rest, and record when each one is closed.

That last clause is what makes it a workflow. A recall with no closure state is a spreadsheet somebody stops updating on day three.

recall: rc-2026-10-09-refund-policy
ingredient:
ref: kb/refund-policy
bad_versions: [r211, r212, r213, r214]
corrected_version: r215
scope:
lots: 1842
deliveries_external: 1130
deliveries_internal: 712
disposition:
ticket: { action: reopen_and_correct, owner: support_ops }
email: { action: notify_recipient, owner: support_ops, template: corr-02 }
report: { action: restate_figure, owner: controller }
code: { action: open_revert_pr, owner: platform }
closure:
require_per_delivery: true
evidence: provenance_store

Disposition by channel, because a closed ticket and a merged pull request need different humans doing different things on different clocks.

5. Rehearse the recall and time it

Food manufacturers run mock recalls. Some operate under a two-hour trace limit.

Do the same thing, and make it boring: pick a document at random, ask which outputs used it and who received them, start a timer.

The first time we ran this the answer was not a slow number. It was a missing capability — we could identify the outputs and not the recipients. No design review surfaces that. A drill surfaces it in twenty minutes.

Run it quarterly, rotate the ingredient type, and record elapsed time as a metric with a target. An untested recall path is an assumption.

Unpinned models are not ingredients you can trace

A handful of details decide whether any of this holds up.

Pin your versions. A model called “latest” is not an ingredient. If the alias moved on 11 July and your records say “latest,” every output on either side of that date is unattributable, and you will find out during the recall. Same for embedding index versions, prompt templates, and tool schemas.

Saved outputs become ingredients. An AI-written summary that gets stored and later read by another agent is now an input to that second answer. derived_from exists for this. Without the transitive closure you recall the first generation and miss the second, which is the version of this failure that embarrasses people publicly.

A lot number tells you which outputs to check, not which are wrong. This is the honest limit of the pattern and you should say it out loud before someone else does. The record tells you what was available to the model, not what it relied on. Some of those eighteen hundred outputs never mentioned the refund policy. Provenance scopes the review; it does not perform it.

Keep the record as long as the output is in use. Not as long as the conversation, not thirty days — as long as the answer still has effects. A figure in an annual report has a long tail.

Store references, not content, and protect the record like the data it points to. A provenance store full of digests and references is a map of your sensitive data. It needs the same access controls as the systems it indexes, and it generally does not get them, because it looks like metadata.

Where this pattern breaks

Mutable pointers. The most common implementation error by a wide margin. The team records kb/refund-policy without a version or digest, because the retrieval layer only ever had the path. Everything looks fine until the first recall, at which point the index tells you which outputs touched a document whose contents you can no longer determine.

Cardinality and cost. A thousand lots a second with twelve ingredients each is twelve thousand edges a second. The edge table grows faster than anything else you own. Partition by time, roll cold partitions to object storage with the index retained, and decide deliberately what you stop indexing. Decide by accident, when the bill arrives, and someone drops the ingredient dimension to save money.

Index lag during the incident that matters. Asynchronous index maintenance is correct for throughput and dangerous during a recall, because the last hour — the hour you most want — may be the hour not yet indexed. Expose index lag and make the recall tool read through to raw lots for anything newer than the current watermark.

Caches and embeddings are ingredients and nobody treats them as such. You fixed the document. The vector index still holds chunks built from the old text, the response cache still serves the old answer, and both produce fresh outputs that cite a corrected source. Fixing the document is not fixing the ingredient.

Human copy-out. Someone pastes an AI answer into a deck and the deck goes to a client. No stamp follows. You can mitigate this at the edges and you cannot eliminate it. Pretending otherwise produces false confidence in recall completeness, which is worse than a known gap.

PII creep. Over a few quarters, “store the query digest” becomes “store the query, it helps debugging.” Now the provenance store is a retention liability. Enforce the schema in CI and reject fields that carry content rather than references.

Adoption: stamp first, index second, recall third

If agents are already in production, you do not get to stop and build this properly. You get to build it in the order where each step is independently useful.

Stamp one surface. Pick the highest-exposure output path — usually the one that talks to customers. Add the interceptor, write records, index nothing. Two weeks of records will tell you whether your retrieval layer even knows the versions of what it fetched. Usually it does not, and fixing that is the real first task.

Pin versions everywhere that produces ingredients. Model aliases, prompt templates, embedding indexes, knowledge base documents. This is unglamorous platform work with no visible output, and skipping it makes every later step cosmetic.

Add the forward trace. Delivery records at the points where output leaves the system. You now have something you could not have before: the ability to answer who received an answer, without yet being able to answer which answers are affected.

Build the index and run a drill. Then the mock recall, timed, with a target. Publish the number internally. A recall time that leadership knows about is a number that gets engineering attention.

Regulators ask who received it, not what your model scored

Audit evidence is a first-class concern in the environment I work in, under SEC and CFTC oversight and Regulation SCI. The questions that arrive after an incident are not about model quality.

They are: what did the system tell customers, over what period, based on what, and who did you notify. Each of those is a provenance query. None of them is answerable from a model evaluation report or an accuracy dashboard.

There is a second-order effect worth planning for. Once you can enumerate affected outputs you are expected to act on the enumeration, and a signed record showing you identified eleven hundred affected customers and notified four hundred is a harder document to explain than one showing you notified everyone. Which is an argument for writing the disposition policy before your first recall, not during it.

Without provenance, your only choices are warn everyone or say nothing

When you cannot tell who a mistake reached, you have two options, and both are bad. Notify your entire customer base about a problem that touched four hundred people and you have manufactured an incident out of an error. Say nothing and hope, and you have made a decision you would struggle to defend.

A lot number gives you the third option: warn the people it reached. That is the whole value proposition, and it is only available if the record was written at the moment of production, because that moment is the only time the ingredients exist in one place.

The painkiller manufacturer did not print that number because it expected a recall. It printed it because recalls happen to everyone eventually, and the number is cheap in advance and impossible in hindsight.

So: if you found a wrong document in your AI’s knowledge base today, how long would it take to list every answer that used it?

The Margin Call Pattern: Budgeting Risk for Autonomous Agents

Your AI agent asks for approval forty times a day.

The first few you read carefully. You check the recipient, you skim the draft, you think about it. Somewhere around the tenth you stop reading and start clicking, and by the afternoon the dialog has become a keystroke — a muscle movement between you and getting your actual work done. You are still technically in the loop. You are no longer a control.

We call this human-in-the-loop. It works more like a rubber stamp.

Removing the approvals is not the answer either. Both failures come from the same place: a check that looks at one action at a time cannot see the thing that actually hurts you, which is accumulation. Two hundred emails that were each individually fine. Fifty small refunds. A folder deleted one file at a time, every deletion defensible, the directory gone by Thursday.

Financial markets worked this out long before AI agents existed. A broker does not approve each of your trades. It watches one number — how much risk you are carrying against what you have put up to cover it. Cross the line and you get a margin call.

Stop gating actions. Start budgeting exposure.

Per-action approval fails at arithmetic, not willpower

Approval fatigue gets described as a human weakness. It is arithmetic. If a reviewer has twenty minutes of attention a day for agent oversight and the agent generates forty requests, each request gets thirty seconds whether it deserves thirty seconds or thirty minutes.

The deeper problem is what the dialog shows. It says “send this email.” It does not say “this is the thirty-eighth outbound message this run has produced, nine of them to external counterparties, none of them read back by anyone.”

Risk is not a property of an action. Deleting one file is a different act when it is the fourth deletion than when it is the four-hundredth. A control that evaluates actions independently is structurally blind to the only variable that matters, and no amount of reviewer discipline fixes a structural blindness.

Three components: ledger, gateway, policy bundle

The five steps only make sense once you know where the state lives, so here is the shape first.

An exposure ledger — a service with its own datastore, deployed separately from any agent. A tool gateway that every agent tool call passes through; we run it as an egress sidecar, though a central service behaves the same way. And a policy bundle holding the pricing rules, the margins per task type, and the hard limits.

The agent never talks to the ledger. The gateway does. The agent asks the gateway to send an email; the gateway identifies the caller, prices the action, reserves exposure against the run, and only then performs the side effect.

Workload identity carries more weight here than it first appears. Every agent process is bound to a SPIFFE identity, and the run’s lineage travels with that identity rather than inside the prompt. An agent cannot claim to be a different run, because the ledger derives the run from the SVID presented at the gateway — not from anything the model wrote.

Hot path is two operations: price and reserve. That needs to land in single-digit milliseconds, so the ledger keeps open exposure in memory behind an append-only write-ahead log. Everything else is asynchronous — evidence export to immutable storage, exposure aging, cap recalculation from track record, reconciliation against observed effects.

1. Price actions by exposure, not by count

Start crude. Read costs 0. A reversible write costs 1. An irreversible action costs 10. Irreversible and external costs 25. Multiply by how many things the action touches.

Pricing in Go:

type Class uint8
const (
Read Class = iota
ReversibleWrite
Irreversible
IrreversibleExternal
)
var base = map[Class]int{
Read: 0,
ReversibleWrite: 1,
Irreversible: 10,
IrreversibleExternal: 25,
}
// Price returns the exposure an action puts on the books.
// BlastRadius is the count of distinct entities the call affects:
// recipients, rows, objects, repositories.
func Price(a Action) int {
return base[a.Class] * max(1, a.BlastRadius)
}

The crudeness is deliberate. Teams stall for a quarter building a defensible risk model and ship nothing; four buckets and a multiplier will teach you more in two weeks of shadow mode.

The multiplier is what per-action gates miss. One email to one customer is 25. One email to four hundred customers is 10,000, and no margin you would reasonably set survives it. The agent cannot reach that action without stopping first — not because someone wrote a rule about bulk email, but because the arithmetic forbids it.

Classification is where the arguments happen. Our rule: reversible means your system can undo it without anyone else’s cooperation, inside the run’s lifetime, and you have tested the undo. A soft-deleted record with an exercised restore path is reversible. A message sitting in someone else’s inbox is not, however good your retraction feature claims to be.

2. Give every run a margin

A margin is a cap on open exposure for a task type. Not per agent and not global — per task type, because “triage inbound support tickets” and “reconcile a settlement break” deserve different numbers and always will.

With a margin of 100, an agent sends four customer emails on its own and the fifth waits.

The ledger entry as it goes on the books:

{
"entry_id": "exp_01HQ7F3A9K2M",
"run_id": "run_7f3a1c",
"root_run_id": "run_7f3a1c",
"lineage": ["run_7f3a1c"],
"principal": "spiffe://platform.internal/ns/agents/sa/refund-assistant",
"task_type": "customer_refund_triage",
"policy_digest": "sha256:9c1f...e07a",
"action": {
"tool": "email.send",
"class": "irreversible_external",
"blast_radius": 1,
"target_digest": "sha256:4b2d...9911"
},
"exposure": 25,
"state": "reserved",
"opened_at": "2026-09-14T15:04:05.221Z",
"settled_at": null,
"release": { "mode": "human_ack", "by": null, "at": null },
"evidence_uri": "s3://agent-evidence/run_7f3a1c/exp_01HQ7F3A9K2M.json"
}

Two fields carry most of the value. policy_digest pins which version of the pricing and margin rules produced this number, so a year later you can say what the control was at the time instead of what it is now. target_digest proves which entities were touched without copying their contents into a risk store that has no business holding them.

The state machine is reserved → committed → released, with reserved → voided when the side effect never happened. You reserve before acting and settle after. Invert that order and a crash between the action and the bookkeeping hands the agent free exposure — the one accounting bug in this pattern that is genuinely dangerous rather than merely annoying.

A reservation that never settles must age into committed with an unknown outcome, never into voided. When you cannot tell whether something happened, carry the risk.

3. Release exposure when work is verified or undone

This is the step that changes agent behaviour and the one most implementations skip.

A reversible change that gets read back and confirmed frees its budget. An irreversible action stays on the books until a human acknowledges it. Nothing decays on a timer, ever.

Which makes verification part of the work instead of a virtue. An agent that writes forty records and never checks them is carrying forty units of exposure and will hit its margin. An agent that writes, reads back, confirms, runs almost indefinitely. You never told it to verify. You made verification the only way to keep going.

The release path in Rego, because this is policy rather than code:

package agents.margin.release
# A reversible write clears once the gateway has observed a
# read-back whose digest matches what was written.
release[entry.entry_id] := "verified" if {
entry := input.open[_]
entry.action.class == "reversible_write"
rb := input.readbacks[_]
rb.entry_id == entry.entry_id
rb.observed_digest == entry.action.target_digest
}
# Irreversible actions never self-clear.
release[entry.entry_id] := "human_ack" if {
entry := input.open[_]
entry.release.mode == "human_ack"
ack := input.acks[_]
ack.entry_id == entry.entry_id
ack.actor_kind == "human"
}

The read-back digest has to come from the gateway’s own observation. An agent that clears its own exposure by reporting success has a budget it sets itself, which is not a budget.

4. The margin call interrupts before the side effect

When the next action would breach the cap, the run pauses before acting. The reservation trips the wire, which means the email has not been sent yet. Everything about the usefulness of this pattern depends on that ordering.

Then one summary goes to a human: what is open, what is next, how much more risk it is asking for.

MARGIN CALL — run_7f3a1c (customer_refund_triage)
Margin: 100 Open exposure: 94 Requested: 25 Breach: 19
Open positions
3 x email.send (external, unacknowledged) 75
19 x ledger.annotate (reversible, unverified) 19
Next action
email.send -> 1 recipient, draft digest 4b2d..9911
Reason: refund denial notice, case CS-44192
Options: acknowledge open items / approve this action / close out

One interruption instead of forty. The human is reading a position rather than a request, and a position is something a person can actually reason about in thirty seconds.

Note what the summary does not include: a recommendation from the agent. We tried that and took it out. The summary describes the book; an agent arguing for its own next action reintroduces precisely the dynamic the pattern exists to break.

5. Close-out is code, not a runbook paragraph

No answer means stop and unwind what is reversible. Write that rule before the run, not during the incident.

Close-out is harder than it sounds, because unwinding half a batch can leave worse state than finishing it. So the policy is per task type and explicit about which direction is safer:

task_type: customer_refund_triage
margin: 100
timeout: 20m
close_out:
mode: unwind_reversible
order: reverse_chronological
on_partial_failure: freeze_and_page
irreversible: leave_open_and_report
hard_limits:
- tool: payment.transfer
effect: deny # never priced, never available
- tool: email.send
max_blast_radius: 25
- tool: object.delete
namespaces: ["prod-*"]
effect: deny

Hard limits sit outside the margin system deliberately. They never consult the ledger and remaining budget does not buy them. For the Kubernetes-shaped ones we enforce with Kyverno at the cluster layer, so an agent with a spotless track record and a full margin still cannot delete a production namespace. Budget buys discretion, not permission.

Four invariants that keep the ledger honest

The ledger lives outside the model. A trader does not set their own margin requirement. If the agent can read its remaining budget, you have built a puzzle for it to optimise against rather than a limit.

Sub-agents draw on the parent’s margin. Spawning twenty workers must not mint twenty budgets. The lineage array exists for this: exposure rolls up to the root run and the gateway rejects any call whose SVID cannot produce an ancestry chain back to an open root. This is where the pattern leaks most often in production, and it leaks silently.

A clean track record raises the cap. Autonomy stops being an argument about trust and becomes a number you move on evidence. We recalculate caps weekly from settled outcomes.

Hard limits hold regardless. The first thing anyone asks for is an exception, which is why this is an invariant and not a guideline.

Where this pattern breaks

Price inflation. Nobody wants to be the engineer who classified the action that caused the incident, so everything drifts toward irreversible-external. Margins trip on action two, the team raises the margin until it stops tripping, and the cap becomes decoration. Watch the distribution of prices, not the breach count. If 80% of your actions cost 25, your taxonomy is dead.

The acknowledgement queue becomes the new rubber stamp. Irreversible exposure needs human acknowledgement, acks back up, agents stall, and someone bulk-clears four hundred items on a Friday afternoon. You have rebuilt approval fatigue one layer down. Treat ack latency as a platform SLO and alert on bulk acks that arrive with no evidence attached.

Decomposition gaming. An agent that cannot afford one irreversible write will find that many reversible writes, read back to clear the budget, then one cheap flip, gets the same result. Nobody teaches it this; it falls out of optimisation. Price cumulative effect over a window alongside per-call price, and watch for runs whose settled exposure sits far below their gross exposure.

Close-out causing the incident. Unwinding a partial batch is itself a change, executed under pressure, usually at a bad hour. Close-out ships with the task type and gets exercised in a game day or it does not exist.

Ledger availability. If the ledger is down, do you fail open or closed? Under Regulation SCI that is not really a choice — an unavailable control is an unavailable control, and you fail closed. Which puts the ledger in the critical path of the whole agent fleet, with the availability engineering that implies. People underestimate this one badly.

Shadow mode first, because you cannot stop the fleet

Four phases. The first changes nothing.

Shadow mode. Put the gateway in front of existing tool calls, price everything, record it, block none of it. Run two weeks. The number you want is peak open exposure per run per task type, and it is reliably higher than the team guesses. This phase also surfaces the taxonomy arguments while they are still free.

One task type, generous margin. Pick the task type with the clearest reversibility story. Set the margin at the 95th percentile of observed exposure so it trips roughly never. Keep every existing approval gate. You are testing plumbing, not policy.

Ratchet and release. Lower the margin until it trips a few times a week, and build read-back verification for that task type’s reversible actions. This is where behaviour changes, and where you start retiring the old per-action gates — one gate, one task type, with the exposure data as the argument.

Earn the caps. Caps move on track record only once settled-outcome data exists. Earned autonomy before you have outcome data is just a bigger margin wearing a story.

Auditors ask what the control did, not what it does

I work under SEC and CFTC oversight, and Regulation SCI shapes how we think about controls on systems that matter. The practical consequence catches teams late: a supervisor never asks what your control does. They ask what it did on 14 September at 15:04, and they expect an answer that does not depend on your current configuration.

A chat transcript where somebody typed “approved” is not that answer. A ledger entry is — policy digest that priced the action, identity that performed it, margin in force, lineage of the run, who acknowledged what and when.

This is why evidence export is a first-class part of the design rather than logging with ambitions. Entries land in immutable storage keyed by run, retained as long as the effects of that run matter.

A second benefit surprised me. Pricing forces a conversation between engineering and risk that otherwise never happens, because someone has to decide out loud whether an action is reversible. That conversation produces better controls than any volume of policy documentation.

Survivability is a lower bar than correctness, and a better one

The shift is one question wide. Stop asking “is this action allowed?” and start asking “how much uncovered risk is this agent carrying right now?”

Markets do not assume every trader is right. They assume someone will be wrong and make sure it is survivable. That is a weaker claim than correctness and a far more useful one, because it is achievable with systems we already know how to build — ledgers, limits, settlement, position reports.

Forty approval dialogs a day is not oversight. It is the appearance of oversight, and the appearance is worse than nothing, because it convinces everyone the question has been handled.

So: how does your team decide when a human gets pulled in today — every action, certain actions, or accumulated risk?

MCP Servers in the Enterprise: Identity, Policy and Blast Radius

A model summarising an incident for us once pulled the contents of a Kubernetes Secret into a transcript.

Nothing was compromised. No attacker involved. The MCP server had been given a read-only role, somebody had written that role the way you write roles for humans — broad read across the API, because reading is harmless — and secrets was in the resource list. The model did what it was asked: gather context about a failing workload. A Secret is context.

That transcript then went where transcripts go, which is a datastore nobody had classified as credential-bearing.

Wiring up an MCP server is an afternoon. Making one defensible took us months. The distance between those two numbers is the whole enterprise story, and most of what is written about MCP is about the afternoon.

“Read-only” is not a security property

The first thing to discard is the intuition that read access is the safe class of access.

Read access to Secrets is write access to production, one indirection away. Read access to a configuration store tells an attacker your topology. Read access to incident history tells them what you are bad at. And in an agent system, every read is a write into a context window that is often persisted, frequently replicated, and almost never classified at the level of the most sensitive thing it has ever held.

The RBAC most teams hand an MCP server is RBAC designed for a human with eyes and attention. An agent has neither. It has recall, throughput, and no concept of “I probably should not have looked at that.”

What we ended up with is narrower than the role any of our engineers hold: an explicit resource allow-list rather than wildcards, no secrets at all, and a redaction pass on tool output that runs before anything reaches the model. The redaction pass catches what the role misses, and it has caught things.

I would also, from day one, have treated the transcript store as a credential-adjacent system with the retention and access controls that implies. We retrofitted that. Retrofitting a data classification is expensive and it is never fully complete.

There are four identities in an MCP call and the spec addresses about one and a half

Walk a single tool invocation and count the principals.

The human. Someone asked a question.

The agent. A model instance acting with some delegated authority, possibly several steps removed from the human’s original request.

The MCP server. A workload with a network identity and its own credentials.

The downstream system. Whatever the tool actually calls, which has its own authorisation model and its own idea of who the caller is.

MCP’s authorisation work has made real progress on the client-to-server leg. OAuth 2.1 patterns, protected resource metadata, resource indicators per RFC 8707 — this is the right direction and it is a genuine improvement over where the ecosystem started.

It authenticates the client to the server. That is one leg of four.

What is missing is a standard, verifiable way to carry the human subject and the delegation chain through to the downstream system, in a form the downstream can enforce on. The confused deputy problem is not a theoretical concern here; it is the default architecture. The server holds credentials broader than any individual caller’s entitlements, and the only thing standing between a caller and the full reach of those credentials is logic the server author wrote by hand.

We solve this today with token exchange — RFC 8693, the server exchanging its own credential plus the caller’s assertion for a narrowly scoped downstream token. For workload identity of the server itself, SPIFFE and the WIMSE work give us short-lived, attested identity rather than a secret in a config. The IETF AIMS effort is pointed at the agent-identity half, which is the piece nobody has solved.

None of that is in the protocol. All of it is bespoke. Every organisation doing this seriously is building the same three components privately, which is a reliable signal that something belongs in a spec.

The static credential we had already removed, reintroduced

One of our tools needed to call an older internal API that only supports static API keys. No OIDC, no mTLS, no roadmap.

So the MCP server held a long-lived key with broad reach. We had spent two years removing exactly that shape of credential — Vault-issued short-lived certificates, mutual TLS between workloads, no static secrets in deployment config — and an agent tool put one back, in a single place, reachable by a model.

The fix was a broker. The tool calls the broker, the broker holds the key, and the broker enforces per-subject scope and rate limits before it touches the legacy API. The tool never sees the credential.

The general rule I now apply: if a tool needs a credential the rest of your platform is not allowed to hold, the tool does not get the credential. Something else holds it and answers narrower questions.

The tool schema describes inputs and says nothing about effects

This is the omission that costs the most, and I want to be direct about it.

An MCP tool definition tells the model what arguments to pass. It does not tell anyone whether invoking the tool reads state, mutates state, costs money, pages a human, or does something that cannot be undone. There is no field for that.

Which means the model cannot reason about consequence, the client cannot ask for confirmation proportionate to risk, and the policy engine has nothing to key on except the tool’s name. A tool called restart_workload and a tool called get_workload_status are, to the protocol, the same kind of thing.

Every enterprise deploying MCP is inventing this field. We did:

{
"name": "scale_deployment",
"effect_class": "mutating",
"reversible": true,
"scope_required": ["namespace"],
"max_blast_radius": { "replicas_delta": 4 },
"approval": "none",
"audit": "required"
}

effect_class is one of read, mutating or irreversible. Nothing registers without it. irreversible requires a second human approval regardless of who is asking, and there is no configuration option to turn that off, because the first thing anyone asks for in an incident is to turn it off.

That declaration is not optional metadata in our setup. It is the registration gate. A server that does not supply it does not get a route.

Put the policy decision outside the server

The pattern I see most often, and would argue against hardest, is the allowlist inside the server.

It does not survive. It lives in the code of whichever team wrote that server, it diverges from every other server’s version, it cannot be audited centrally, and when you need to answer “which tools could this user invoke last Tuesday” you have to read source control history.

Treat the MCP server as a policy enforcement point and nothing more. It asks an external decision point, in band, on every call:

package mcp.authz
default allow := false
allow if {
input.effect_class == "read"
input.subject.groups[_] == "platform-engineering"
not sensitive_resource(input.arguments)
}
allow if {
input.effect_class == "mutating"
input.subject.groups[_] == "platform-engineering"
input.arguments.namespace in data.subject_scopes[input.subject.id].namespaces
input.agent.attested == true
}
# irreversible effects never pass on policy alone

Two things in there matter more than the syntax. input.agent.attested means the agent’s identity was verified, not asserted — the difference between a workload presenting a short-lived attested credential and a header saying who it claims to be. And the decision is keyed on effect_class, which only works if the declaration above is mandatory.

The output of this call is a decision with an identifier. That identifier goes in the audit event. Which brings me to the part everyone defers.

“We have logs” is not an audit trail

We could show tool invocations in server logs. We could show prompts in application logs. We had no shared identifier between them.

So when we wanted to reconstruct a session — which tools ran, on whose behalf, in what order, against which policy decisions — we joined on timestamps. Across systems with different clocks and different buffering behaviour.

That is not evidence. Under Regulation SCI, on a platform settling on the order of $4.5 trillion a day, “we correlated the logs by hand afterwards and we are fairly confident” is not an answer you want to give. The standard is a record that was captured at the time, not assembled later from fragments.

The event shape we settled on:

{
"session_id": "s-01JA9...",
"request_id": "r-01JA9...",
"subject": { "id": "user:redacted", "auth_method": "oidc", "mfa": true },
"agent": { "id": "spiffe://internal/agents/ops-assistant", "attested": true },
"delegation": { "granted_at": "2026-09-11T14:02:08Z", "scopes": ["read:workloads"] },
"server": { "id": "spiffe://internal/mcp/cluster-inspector" },
"tool": { "name": "get_workload_status", "effect_class": "read" },
"arguments_hash": "sha256:7c1e...",
"policy": { "decision": "allow", "decision_id": "d-01JA9...", "bundle": "mcp-authz@2026.09.3" },
"result": { "status": "ok", "redactions_applied": 2 }
}

arguments_hash rather than arguments, because arguments contain the things you do not want in a log. bundle so you can prove which version of policy made the decision. delegation so the chain from human to agent is in the record rather than inferred.

MCP has no canonical audit event. Everyone is inventing one. This is a worse outcome than a mediocre standard event, because it means no tooling, no shared detections and no portable evidence.

We shipped read-and-recommend agents fast and mutating agents slowly, deliberately

Our generative-AI operations assistant runs on Amazon Bedrock with retrieval-augmented generation and evaluation sets behind it, and it cut routine operational queries by about 50%. Agents doing anomaly detection, monitoring and incident response cut mean time to repair by roughly 60%.

Both of those numbers came from systems that read and recommend. A human still acts.

We have moved much more slowly on agents that mutate state, and that was a choice rather than a limitation. The risk profile is not adjacent, it is different in kind. A read agent that is wrong wastes an engineer’s time. A mutating agent that is wrong at 03:00, on a platform where an outage is a market event, has a blast radius measured in things you cannot take back.

The 60% figure is the one people quote at me, and I think it is the less interesting number. The interesting one is that we got it without granting write access to anything.

If you are being pushed to give agents mutating authority to show progress, that is the counter-argument. Most of the value in operational AI so far is in retrieval and correlation, which are read problems.

Tool descriptions are prompts, and the server writes them

A tool description is text supplied by the server that goes directly into the model’s context and influences its behaviour. That is a prompt. Supplied by a third party. Mutable at any time, with no attestation and no version pinning in the protocol.

A compromised or hostile server can rewrite a description to redirect behaviour — and the model has no way to distinguish an instruction that came from its operator from one that came from a tool catalogue.

Tool results are the same problem in the other direction. Output from a tool is untrusted content, and treating it as trusted input to the next reasoning step is how you get an agent following instructions that were planted in a log line.

We already solved a version of this problem for container images: sign the artifact, constrain the signer identity, verify at admission, alert on drift. The reasoning transfers directly. Sign the tool manifest, verify it at registration, pin the version, and treat a changed description as a change requiring review rather than a silent update.

That is not in the spec either, and of everything on my list it is the one I would want first.

The spec needs three fields more than it needs more features

If I could put three things into the protocol and nothing else, it would be these.

An effect declaration on every tool. Read, mutating, irreversible, with reversibility and scope requirements. Without it, no client can ask for proportionate confirmation and no policy engine can decide anything meaningful.

A delegation claim. A verifiable statement of which human authorised which agent for which scope, carried through the call and enforceable by the downstream system. Not a header. A signed assertion.

A canonical audit event. One shape, with subject, agent, server, tool, effect class, policy decision and correlation identifiers. Mediocre and universal beats excellent and private.

None of those are features. They are the fields that make governance possible at all, and until they exist every serious deployment will keep rebuilding the same three components privately and incompatibly.

The thing I would tell anyone starting this work: the hard part was never wiring the tool up. It was governed access, policy that lives outside the server, and an audit trail that holds up when someone asks you to reconstruct a decision six months later. Build the registration gate before the first server reaches production. We built it third, and paid for the ordering.

Multi-Cluster Calico Enterprise on eBPF: Notes From the Deep End

Flipping the dataplane took about forty seconds. Dealing with what it broke took three weeks.

That ratio is the honest summary of an eBPF migration, and almost nothing written about eBPF networking tells you about it. The posts are about throughput graphs and iptables rule counts. The work is about conntrack, kernel versions, asymmetric return paths and a consumer group that will not stop rebalancing.

We run multi-cluster Calico Enterprise with the eBPF dataplane across AWS regions and on-premises data centres, on a Kubernetes platform behind clearing flows on the order of $4.5 trillion a day. We hold 99.99%. I am going to describe what went wrong, because the failure modes are the transferable part.

We moved for policy evaluation cost, not for benchmark numbers

The usual case for eBPF is latency and throughput. That is not why we moved.

As Service count and NetworkPolicy count grew, two things got uncomfortable. kube-proxy service programming time grew with the number of Services, so a large deployment event produced a window where some nodes had a stale view of endpoints. And conntrack table pressure under long-lived connection counts was close enough to limits that we were tuning rather than ignoring it.

Add two more wants: real source IP preservation for workloads that make authorisation decisions on client identity, and flow-level visibility we could actually query.

The latency improvement was welcome. It was never the justification. If someone pitches you an eBPF migration on p99 numbers alone, the migration will not survive its first incident review.

Flipping the dataplane resets every established connection

The switch itself is small. Patch the Installation to linuxDataplane: BPF, then stop kube-proxy from programming anything:

kubectl patch installation.operator.tigera.io default --type=merge \
-p '{"spec":{"calicoNetwork":{"linuxDataplane":"BPF"}}}'
kubectl patch ds -n kube-system kube-proxy --type=merge \
-p '{"spec":{"template":{"spec":{"nodeSelector":{"non-calico":"true"}}}}}'

Two commands. What they do not tell you is that the service and conntrack state moves from kernel netfilter tables into BPF maps, and existing entries do not come with it.

Every established TCP flow through the node is now unknown to the dataplane.

For request/response HTTP traffic this is close to invisible. A connection resets, the client retries, nobody files a ticket. We validated in lower environments with exactly that kind of traffic and saw nothing.

Then we did it in an environment carrying Kafka.

The rebalance that would not settle

Kafka clients hold long-lived TCP connections with mutual TLS. When the dataplane flipped, those connections reset en masse. Producers started retrying inside their delivery timeout. Consumers dropped out of their groups, which triggered a rebalance, which took long enough under load that some members timed out during the rebalance, which triggered another rebalance.

It converged. It took far longer than the forty seconds the patch took, and for a stretch the lag graphs looked like a broker failure rather than a network change.

The lesson generalises past Kafka. Any dataplane change is a connection-reset event, and your validation environment probably has no long-lived connections in it. Load generators produce short flows. Databases, message brokers, gRPC channels with keepalives and mTLS sessions with session resumption do not.

Before you flip anything, inventory what holds connections open for hours.

DSR preserves source IPs by making your return path asymmetric

bpfExternalServiceMode: DSR is how you get real client IPs for traffic arriving through an external load balancer. The return packet goes straight from the pod’s node to the client instead of back through the balancer.

In a single-cloud topology with a network load balancer, this works well.

We are hybrid. Some ingress paths traverse stateful appliances on the way to on-premises sites. A stateful device that saw the forward flow and never sees the return flow holds a half-open entry; a device that sees a return flow it has no forward record for drops it. Asymmetric routing and stateful inspection are a long-standing incompatibility, and eBPF DSR walks straight into it.

The symptom was not a clean failure. Connections established and then stalled, intermittently, on one ingress path only. We spent longer than I would like looking at application logs.

What I would do differently: start in Tunnel mode, characterise every ingress path end to end, and move individual paths to DSR deliberately. We enabled DSR cluster-wide because the documentation presents it as the better option, and for most of our paths it was. “Most” is not a deployment strategy.

MTU is the failure that presents as an application bug

Our on-premises connectivity runs over encrypted 10 Gbps links, and the clusters sit inside a hub-and-spoke design spanning more than fifty VPCs. Overlay encapsulation and encryption each take their bite out of the path MTU.

When the dataplane changed, our MTU assumption no longer matched reality on one path.

The signature is specific and worth memorising. TLS handshakes complete — those packets are small. Metadata calls succeed. Then any transfer large enough to produce full-size segments hangs until a timeout. If path MTU discovery is being blackholed by something dropping ICMP fragmentation-needed, nothing in your logs will say “MTU.”

Set it explicitly rather than inferring it:

apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
linuxDataplane: BPF
mtu: 8941

Then verify on a node rather than trusting the manifest, because a mismatch between the configured value and the interface is exactly the bug:

ip link show | grep -E 'mtu' | head
calico-node -bpf counters dump | grep -i drop

Add an explicit large-payload test to your post-migration checklist. Not a health check. A multi-megabyte transfer across every path that leaves the cluster.

Kernel version is a platform contract, and we did not have one written down

Our AWS node images ran a 5.x kernel. Our on-premises RHEL nodes ran 4.18 with vendor backports. Calico supports both, which is the detail that made this hard rather than easy, because it meant nothing failed loudly.

What happened instead is that feature availability diverged by node pool. Some acceleration options were available on one side and not the other. Behaviour that we validated on one pool did not reproduce on another, and for a while we were chasing what looked like a nondeterministic bug but was a fleet with two different dataplanes wearing the same label.

eBPF makes your kernel a first-class platform dependency. Treat it that way. Pin a kernel floor as a documented contract, gate node image promotion on it, and refuse to run a mixed fleet through a dataplane migration.

This was the single largest time sink of the project, and it was entirely self-inflicted.

Failsafe ports are load-bearing and the defaults are not sufficient

If you apply host endpoint policy with a global default-deny, you are now writing firewall rules for the node itself, including the paths the node uses to stay in the cluster.

Failsafe ports exist for this. The defaults cover the obvious ones. They did not cover everything our on-premises nodes needed, and we made one node group unreachable in a way that required out-of-band access to fix.

Declare them explicitly, including the ones specific to your environment:

apiVersion: projectcalico.org/v3
kind: FelixConfiguration
metadata:
name: default
spec:
bpfEnabled: true
bpfExternalServiceMode: Tunnel
failsafeInboundHostPorts:
- { protocol: tcp, port: 22 }
- { protocol: tcp, port: 6443 }
- { protocol: tcp, port: 2379 }
failsafeOutboundHostPorts:
- { protocol: udp, port: 53 }
- { protocol: tcp, port: 6443 }

Apply host endpoint policy to one node you can afford to lose, first. Every time.

Federation turns every label selector into a global object

Multi-cluster Calico is not a routing feature. It is an identity feature. Federated endpoint identity means a policy in one cluster can select workloads in another, by label.

Which means the moment you enable it, your label namespace is a security boundary, and it was almost certainly not designed to be one.

apiVersion: projectcalico.org/v3
kind: RemoteClusterConfiguration
metadata:
name: peer-region-b
spec:
clusterAccessSecret:
name: peer-region-b-kubeconfig
namespace: calico-system
syncOptions:
overlayRoutingMode: Enabled

We had an allow rule written against a selector along the lines of app == "gateway", scoped by intent to a single cluster. After federation, that selector matched endpoints in the peer cluster too, and the rule permitted more than it was written to permit.

Nothing was exploited. We found it in flow logs during a review, which is the good version of finding it.

Two fixes, and I would apply both before enabling federation rather than after. Require a cluster or region label in every selector that could match federated endpoints. Then enforce that requirement in CI with admission policy, because the convention will not hold on its own.

Writing policy against a globally shared label space, with no tooling to tell you what a selector will match after federation, is the sharpest edge in multi-cluster Calico. Budget review time for it.

eBPF flow logs will outgrow your logging budget before they finish being useful

The visibility is genuinely good. The volume is genuinely a problem.

Default aggregation produces far more records than netfilter-based logging did, because you are now seeing things you previously could not. We had to tune aggregation and retention within the first week rather than treating it as a later optimisation. Our ingest estimate was off by roughly an order of magnitude — illustrative, since it depends entirely on your flow mix.

Decide up front which flows you need at full fidelity for compliance evidence and which you need only in aggregate, and set flowLogsFileAggregationKindForAllowed accordingly. Full fidelity on denied flows. Aggregate on allowed. Revisit when an investigation shows you what you actually wanted.

tcpdump is no longer telling you the truth

The debugging habit that costs the most time is reaching for tcpdump and trusting what comes back. In eBPF mode, traffic can be redirected before it reaches the point your capture is attached to, so a missing packet in a capture is not evidence of a missing packet.

Use the dataplane’s own view:

calico-node -bpf policy dump eth0 all
calico-node -bpf conntrack dump
calico-node -bpf counters dump

The counters are where the answer usually is. A drop counter incrementing against a specific reason tells you more in ten seconds than an hour of packet captures from the wrong vantage point.

Migrate by node group, not by cluster

If I ran this again, I would not flip a cluster’s dataplane in place. I would build a new node group already in eBPF mode, cordon and drain the old group, and shift workloads across under pod disruption budgets.

That changes the character of the whole exercise. Connection resets become a rolling, paced event instead of a cluster-wide one. Kernel skew gets eliminated by construction, because the new group has one image. And the rollback is “cordon the new group and drain back,” which is a procedure rather than a second migration under pressure.

We flipped in place because the documentation describes it as supported, and it is. Supported and reversible are different properties, and on a platform where an outage is a market event, only the second one matters.

The other thing I would change is smaller and more cultural. We treated this as a networking project. It is a connection-lifecycle project, and the people who needed to be in the room early were the ones who own the brokers, the databases and the long-lived client pools — not the ones who own the CNI.

A Software Supply Chain That Survives a Regulator: SLSA, Sigstore and SBOMs in Practice

The question that reorganised our entire supply chain programme took about eleven seconds to ask.

Not “do you sign your images.” Not “do you scan for vulnerabilities.” It was: for the container that was running in production on that date, show me the commit it came from, show me the build that produced it, and show me that the build could not have been altered by the person who requested it.

That last clause is the design problem. Everything before it is tooling.

I own platform architecture for a Kubernetes-based clearing platform that settles on the order of $4.5 trillion a day across three regions, with on-premises data centres alongside AWS. We hold a 99.99% availability target, under SEC and CFTC oversight, with Regulation SCI behind every change. An outage is not an incident report. It is a market event.

Under that constraint, supply chain security stops being a security feature. It is an evidence-production system that also happens to block bad builds.

SLSA levels are a list of people you have to trust, not a maturity score

Teams read the SLSA ladder as a score and then optimise the score. Read it instead as an answer to one question: who has to be honest for your provenance to be true?

Level 1 means provenance exists. Anyone can forge it.

Level 2 means the build service signed it. Now you trust the build service’s word, and anyone who can modify a pipeline can change what the build service says.

Level 3 means the provenance is non-falsifiable. The build runs in an isolated, ephemeral environment and the signing identity belongs to the build platform, not to anything a pipeline author controls. The person who wrote the pipeline cannot make the provenance lie.

Only that last jump matters in an audit, because Level 2 is “the build system says so” and Level 3 is “the build system could not have said otherwise.” If a developer can inject a step that writes arbitrary provenance, you have a self-signed claim with extra ceremony.

A trimmed predicate, so the shape is concrete:

{
"_type": "https://in-toto.io/Statement/v1",
"subject": [{
"name": "registry.internal/clearing/matching-svc",
"digest": { "sha256": "9f2c...e81a" }
}],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://internal/build/container/v2",
"externalParameters": {
"repository": "git+https://internal/scm/clearing/matching-svc",
"ref": "refs/heads/main"
},
"resolvedDependencies": [
{ "uri": "git+https://internal/scm/clearing/matching-svc",
"digest": { "gitCommit": "4c1b9e0" } }
]
},
"runDetails": {
"builder": { "id": "https://internal/build/runners/hardened-pool" },
"metadata": { "invocationId": "b-2026-08-14-00921" }
}
}
}

Three fields carry the audit. builder.id is how you argue isolation. resolvedDependencies is how you answer “built from the commit that was reviewed.” The subject digest is what ties the statement to something that actually ran.

The digest. Not the tag. A tag is a mutable pointer, and a mutable pointer in an evidence chain is a defect.

Keyless signing converted our key custody problem into an availability problem

We went keyless. The build workload presents an OIDC token, a short-lived certificate gets issued against that identity, the signature and certificate land in a transparency log, and the certificate expires in minutes.

This was not a novelty choice. Our identity model everywhere else is short-lived certificates and mutual TLS between workloads on enterprise PKI. We had spent years removing long-lived static credentials. Introducing a signing key with a five-year life and a custody procedure would have been a regression dressed as a control.

What we underweighted: verification at admission now depends on reaching a transparency log.

The afternoon new pods stopped scheduling

One cluster runs a deliberately tight egress allow-list. A routine change to that list dropped the Sigstore endpoints. Verification was set to fail closed, which is correct.

Nothing happened for hours. Running pods kept running — admission had already cleared them.

Then a node group scaled out during a busy window, and every pod landing on the new nodes failed admission because verification needed a log lookup it could no longer make. The platform did exactly what we had configured. We had never tested the configuration.

What changed afterwards:

  • Verification material travels with the artifact. Signature, certificate and inclusion proof ship as a bundle, so verification succeeds without a live call.
  • The trust root is mirrored in-region, with its own availability target and its own page.
  • The transparency log is on the tier-one dependency map. Anything that can stop your platform scheduling belongs there.

Fail-closed is the right default. Fail-closed on an uncharacterised external dependency is a deferred outage with good intentions.

A signature proves somebody signed; only an identity constraint proves you signed

We run Gatekeeper and Kyverno together, which draws the occasional complaint about redundancy. The split is deliberate. Gatekeeper takes the constraints that need Rego and need to reason across resources. Kyverno takes image verification, attestation checks and mutation, where its primitives sit closer to the problem and the resulting policy is still readable six months later.

The part of the policy that does the work:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-build-provenance
spec:
validationFailureAction: Enforce
rules:
- name: require-signature-and-slsa-provenance
match:
any:
- resources: { kinds: ["Pod"] }
verifyImages:
- imageReferences: ["registry.internal/clearing/*"]
mutateDigest: true
required: true
attestors:
- entries:
- keyless:
subject: "https://internal/build/runners/hardened-pool/*"
issuer: "https://oidc.internal/build"
attestations:
- type: https://slsa.dev/provenance/v1
conditions:
- all:
- key: "{{ predicate.buildDefinition.externalParameters.ref }}"
operator: Equals
value: "refs/heads/main"

subject and issuer are the entire point of that block. Without them you have built a system where anyone holding a valid Sigstore identity can sign your production images, and your policy will approve it.

This is where most implementations I have reviewed quietly enforce nothing. Policy passes. Dashboard is green. Control is decorative.

mutateDigest: true matters for a different reason: it rewrites tags to digests at admission, closing the gap between what you verified and what the kubelet pulls.

Generate the SBOM from the artifact, not from the recipe

An SBOM is not a control. It is an inventory you query under time pressure at an hour you did not pick. The only question it has to answer is which running things contain this component at this version, and how fast you can say so. Hours is bad. Days is a disclosure event.

We got this wrong twice.

We generated from the build context instead of the built image. Scanning the source tree and the manifest produced a tidy list that omitted everything the base image contributed. A base image refresh pulled in an undeclared package, and our inventory insisted it was not present. The recipe tells you intent. The artifact tells you what shipped.

We attached attestations to the wrong digest. Multi-architecture builds produce an image index plus per-platform children, each with its own digest. Our pipeline attached SBOM and provenance to the index. Verification passed in the pipeline and failed in the cluster, on one architecture only, which made it look intermittent for far longer than it deserved.

That one surfaced in a drill rather than an incident, which is the only comfortable sentence in this section.

Generate by digest, attach to every child:

syft "registry.internal/clearing/matching-svc@${DIGEST}" \
-o spdx-json > sbom.spdx.json
cosign attest --yes \
--predicate sbom.spdx.json \
--type spdxjson \
"registry.internal/clearing/matching-svc@${DIGEST}"

Control counts are not an achievement; aggregation is

Supply chain controls do not stand alone, and running them as a standalone programme is how you end up with a correctly signed image in an account that should not exist.

Ours sit inside four other layers. Service control policies deny whole classes of action at the account boundary regardless of local IAM. Sentinel policies fail infrastructure changes at plan time rather than apply time. AWS Config watches continuously for drift, because something always drifts. Admission handles signature, provenance and configuration.

Together those aggregate 2,041 active controls into Security Hub.

I quote that number carefully, because control counts get presented as outcomes and they are not. Two thousand controls across five consoles with no shared severity model is worse than two hundred in one place. The aggregation is the work. The count is a byproduct.

The break-glass path is the control a regulator is actually testing

Strip the paperwork away and the asks reduce to three. An inventory of what is running and what it was built from. Evidence for one specific artifact: commit, build, approver, signature, verification decision. And a demonstration that the control cannot be bypassed.

The third is the difficult one, and it is almost never about cryptography.

You have a break-glass path. Everyone does. On a platform with a market-facing clock, somebody will eventually need to deploy something non-conforming at 03:00 to stop a settlement cycle slipping. The only question is whether that path was designed or improvised.

Build the exception register before you turn enforcement on

We did it the other way round, and that is the decision I would reverse first.

For about a quarter, our emergency path was a human with cluster-admin and a chat thread. Nothing broke. But every use of it produced evidence reconstructed afterwards from memory and timestamps, rather than captured at the moment of the decision. Reconstructed evidence is the weakest thing you can bring to an audit, and it is weakest precisely where the stakes were highest.

Make the exception a first-class object before the first enforcing policy ships: time-bound, signed, named approver, read by the policy engine, expiring on its own, emitting an event when used. You get identical operational flexibility and evidence that was recorded rather than remembered.

The smaller correction: our first enforcing policy went cluster-wide in audit mode, then flipped to enforce. Audit mode told us which workloads were violating. It said nothing about workloads that did not exist yet — scale-out paths, DaemonSet upgrades, vendor operator images nobody had inventoried. Scope the first enforcement to one namespace containing a workload that scales, and leave it there longer than feels necessary.

What surprised me most about this work is how clerical it turned out to be. Signing is easy. Verification is easy. Producing a defensible chain of custody, on demand, for an artifact that ran four months ago is a records-management problem wearing a cryptography costume. Design for the question you will be asked rather than the demo you want to give.

Policy as Governance: What OPA and Kyverno Actually Buy You at Audit Time

The question that matters is never “do you have policies.”

It is closer to this: on 3 March, a workload ran in this cluster with a mutable image tag. Name the control that should have prevented it. Show me that the control was in effect on that date. Show me every exception that was open at the time, who approved each one, and when it expires.

Three artifacts. An admission controller produces the first one for free. It produces the second and third only if you built for them, and almost nobody does, because the demos are all about blocking a bad pod.

I run policy as code at admission across a multi-region Kubernetes platform sitting behind roughly $4.5 trillion of daily settlement under SEC and CFTC oversight and Regulation SCI. The enforcement was the easy part. The evidence took longer than the enforcement, cost more engineering hours, and is the only part the regulator ever looked at.

Blocking is the cheap half

An OPA/Gatekeeper constraint that rejects a privileged container is a weekend of work. Rolling it out without breaking three teams is a month. Proving on demand that it was enforcing continuously for eighteen months, across every namespace, with a complete exception register, is a system.

That system is the actual product. The policy engine is a component inside it.

Most policy-as-code programmes I review have inverted this. They have four hundred policies and no durable record of which ones were enforcing last quarter. They can tell you about today. Audit is rarely about today.

One layer is not a control

Our governed cloud foundation runs three enforcement layers, and I would not drop any of them:

  • Preventive, as service control policies. The action is unavailable, so there is nothing to remediate and nothing to argue about.
  • Detective, as AWS Config rules feeding Security Hub, with GuardDuty and IAM Access Analyzer alongside. We aggregate 2,041 active controls drawn from the AWS Foundational Security Best Practices and CIS Benchmarks.
  • Pipeline, as HashiCorp Sentinel policies evaluated against the Terraform plan before anything reaches an account.

The pipeline layer is the one teams underinvest in, and it is the one that changes behaviour. A denial at plan time arrives while the engineer is still holding the context. A detective finding arrives three days later as a ticket, addressed to whoever owns the account this quarter.

A Sentinel policy that refuses untagged or unencrypted storage at plan time:

import "tfplan/v2" as tfplan
required_tags = ["data-classification", "cost-center", "control-owner"]
buckets = filter tfplan.resource_changes as _, rc {
rc.type is "aws_s3_bucket" and
rc.mode is "managed" and
rc.change.actions contains "create"
}
main = rule {
all buckets as _, b {
all required_tags as t {
keys(b.change.after.tags) contains t
}
}
}

control-owner is the tag that earns its place. It is not a cost attribute, it is the name that goes next to a finding, and making it mandatory at plan time removed an entire category of orphaned remediation work.

Account Factory for Terraform, because controls should speak the language your engineers already review

We deployed AWS Control Tower through Account Factory for Terraform rather than the CloudFormation path.

That decision got pushback, and the reasoning held up. The organization’s infrastructure standard was Terraform. Governance delivered as CloudFormation would have meant two IaC dialects, two review processes, two definitions of drift, and a control plane that no engineer on the platform team could read fluently. Controls that your own engineers cannot review are controls you are trusting rather than operating.

Account provisioning moved from three to four months down to days. The headline number people quote is the speed; the part that mattered more was that break-glass roles replaced manual credential rotation across more than 50 accounts, and compliance reporting and tagging became generated output rather than a quarterly spreadsheet exercise.

Gatekeeper versus Kyverno is a tooling question with a boring answer

Run both. The argument is a waste of a design review.

Reach for Rego and Gatekeeper when the decision needs state beyond the object under review: referential constraints, uniqueness across a namespace, arithmetic over a set of existing resources. Rego is a query language and that is what it is good at.

A constraint template that enforces image provenance by registry, which is deliberately dull:

package k8srequiredregistry
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not startswith(container.image, input.parameters.registry)
msg := sprintf("image %v is not from the approved registry %v", [container.image, input.parameters.registry])
}

Reach for Kyverno when you need to mutate, generate or verify. Mutation and resource generation in Rego is possible and unpleasant. Kyverno policies are YAML, which means a compliance analyst can read one and confirm it says what the control document claims it says. That property is worth more at audit time than elegance.

The cost of running both is two policy languages in the platform. The benefit is that neither one is being stretched past what it was built for, and I have seen far more damage from a heroic Rego mutation webhook than from maintaining two engines.

Provenance nothing verifies at admission is a file

We built a signed supply chain from commit to runtime: SLSA provenance generated in the build, Sigstore signatures, an SBOM per artifact.

For a while, all of that was paperwork.

The attestations existed. The build emitted them. Nothing refused to run an image that lacked them, so in control terms we had documentation, not enforcement. The gap between “we generate provenance” and “unprovenanced images cannot start” is the entire control.

The Kyverno policy that closes it:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-slsa-provenance
annotations:
control.internal/id: "SC-SUP-004"
control.internal/owner: "platform-security"
spec:
validationFailureAction: Enforce
background: false
rules:
- name: require-signed-provenance
match:
any:
- resources:
kinds: ["Pod"]
verifyImages:
- imageReferences:
- "registry.internal/*"
attestations:
- type: https://slsa.dev/provenance/v1
attestors:
- entries:
- keyless:
issuer: https://token.actions.githubusercontent.com
subject: "https://github.com/org/*"
conditions:
- all:
- key: "{{ buildDefinition.externalParameters.workflow.ref }}"
operator: Equals
value: "refs/heads/main"

Two details carry the audit weight. The control.internal/id annotation maps this policy to a control in the register, so the evidence export can be generated rather than assembled by hand. And the condition on the build ref means a signed image built from a feature branch is still refused, which is the failure mode people miss when they treat signature verification as a boolean.

Audit wants three artifacts and your dashboard supplies none of them

The control, expressed once and mapped

Every policy needs a control ID and one named owner. We wrote policies for eighteen months and mapped them to controls afterwards, and the mapping exercise was worse than writing the policies. Several policies turned out to enforce overlapping fragments of three different controls, which is impossible to explain in a room.

State over time, not state now

A policy engine reports the present. Audit asks about a date. You need policy version, enforcement mode and effective date range, retained for as long as your record-keeping obligation runs.

Extracting enforcement state as evidence rather than as a dashboard:

kubectl get constraints -o json \
| jq -r '.items[]
| [ .kind,
.metadata.name,
(.spec.enforcementAction // "deny"),
(.metadata.annotations["control.internal/id"] // "UNMAPPED"),
(.status.totalViolations // 0) ] | @tsv' \
| tee "evidence/constraints-$(date -u +%Y-%m-%dT%H:%M:%SZ).tsv"

Run it on a schedule, sign it, retain it. Unglamorous, and it answered the examiner’s question in minutes rather than the week the first request took us.

The exception register

Exceptions have to be objects with an owner, a justification and an expiry. A namespace in an exclusion list is not an exception. It is an invisible exemption, and I have the scar to prove it.

A constraint sat in dry run for months and we counted violations as blocks

This is the failure I bring up whenever a team tells me their coverage is good.

A constraint governing a meaningful control ran at enforcementAction: dryrun for months after rollout. The dashboard showed violations. Everyone read violations as blocked attempts, because that is what a policy engine is assumed to mean.

When audit asked for the number of exceptions, the honest answer was that the violation count was the exception count. Every one of those workloads had been admitted.

What changed afterwards: enforcement mode is a field in every evidence record, dry-run constraints are reported in their own section with a mandatory expiry date, and a constraint that has been in dry run for more than 30 days fails a scheduled pipeline check. Dry run is a migration state. Treating it as a permanent operating mode is how programmes end up with four hundred policies and no enforcement.

The exclusion list ate a tenant and the board stayed green

The second one was quieter and worse.

During an incident we added a namespace exclusion so a platform component could be deployed. The exclusion used a prefix match. Our namespace convention meant that prefix also matched every namespace belonging to one tenant.

That tenant was exempt from several constraints for a long time. Nobody noticed, because the dashboard showed zero violations for those namespaces, and zero violations looks exactly like compliance.

Absence of violations and absence of coverage must be visually and structurally distinguishable. We now generate exclusions from a registry with expiry dates, and the evidence set includes a coverage report listing namespaces not matched by each constraint. That report is the one I would build first if I were starting over.

The operational scar, briefly

Separately: failurePolicy: Fail is the correct setting and it will bite you during a cluster upgrade. Policy pods drained, admission stalled, and the break-glass instinct in the room was to delete the webhook configuration. We now run the policy controllers on a dedicated node pool with a priority class and a pod disruption budget, and the documented break-glass is a role assumption that fires an alert, not a kubectl delete that leaves no trace.

A lab where a policy can actually fail beats a catalog of policies that never have

I also work as principal cloud security architect on a policy-as-code guardrails programme for a large international institution, pushing CIS policies through an Azure DevOps pipeline into a dedicated lab.

The lab is the deliverable. A policy that has never denied anything is an untested assertion, and a catalog of untested assertions is how compliance theatre gets built. Every policy ships with a test that proves a deny against a deliberately non-compliant resource.

A test case that fails the build if the policy stops denying:

apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
name: verify-slsa-provenance
policies:
- ../policies/verify-slsa-provenance.yaml
resources:
- resources/pod-unsigned.yaml
- resources/pod-feature-branch-signed.yaml
results:
- policy: verify-slsa-provenance
rule: require-signed-provenance
resources: [pod-unsigned, pod-feature-branch-signed]
result: fail

Those tests became audit artifacts in their own right. Showing an examiner a pipeline that fails when a control stops working is a stronger statement than showing them the control.

Write the evidence pipeline alongside your first policy, not after your first audit

If I could change one decision, it would be this one, and it is not close.

We built mature enforcement and immature evidence. Reconstructing eighteen months of control state from git history, cluster snapshots and Slack archaeology cost more engineering time than authoring every policy we had. None of that work made the platform safer. It only made the platform explainable, which we should have been from day one.

Start with ten policies that map to controls someone will be asked about. Give each one an owner, an ID, an expiry-bearing exception path, and a signed daily evidence export. Then add the eleventh.

Policy as code is not a security tool that happens to help with compliance. It is the mechanism by which an architecture becomes auditable, and if it does not produce evidence, it is a very expensive linter.

The Confused Deputy Is Back, and This Time It Has an LLM

A junior analyst on a reporting team has access to her team’s shared drive. Not finance. Not HR. She opens the internal assistant and asks it to summarize the company payroll spreadsheet by department.

The agent resolves the document. The retrieval service fetches it using the platform’s service credential, because that is the only credential the retrieval service has ever had. The gateway forwards the content to the model provider over a federated token that expires in fifteen minutes. The model returns a tidy table of salary bands by level.

Every token in that chain was valid. Every hop ran over mutual TLS with a certificate our own PKI issued an hour earlier. The audit log records a successful, authenticated, authorized call with a complete request trace.

She now knows what her manager earns.

Nothing was breached. The system did exactly what it was built to do, which is why no detection rule you own will ever fire on it.

Norm Hardy wrote this up in 1988. A compiler ran with privileges its callers lacked, callers handed it an output filename, and one of them handed it the name of the billing file. The deputy was confused about whose authority it was acting under. That was the entire exploit, and we have spent nearly forty years building disciplines around it: capability passing, OAuth 2.0 Token Exchange, on-behalf-of flows, SPIFFE.

Then we put an inference gateway in front of a model provider, gave it a credential with broad reach, pointed an agent framework at it, and called the identity problem solved because we had removed the static key.

Workload identity federation authenticates your platform, not your user

I architect a clearing platform where Kubernetes and Kafka run multi-region across AWS and our own data centres behind roughly $4.5 trillion of daily settlement, a 99.99% availability target, SEC and CFTC oversight, and Regulation SCI. The first question an examiner asks about an AI feature is not which model you chose. It is who authorized this access, and show me the record.

So we did the obvious thing first and federated the gateway’s identity.

The trust relationship, constrained to one namespace and one service account:

resource "google_iam_workload_identity_pool_provider" "inference_gateway" {
workload_identity_pool_id = google_iam_workload_identity_pool.platform.workload_identity_pool_id
workload_identity_pool_provider_id = "cluster-inference-gw"
oidc {
issuer_uri = var.cluster_oidc_issuer
allowed_audiences = ["sts.googleapis.com"]
}
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.namespace" = "assertion[\"kubernetes.io\"][\"namespace\"]"
}
attribute_condition = "attribute.namespace == \"ai-gateway\""
}

That is good engineering and I would defend it in any review. It is the same pattern we used to delete long-lived static credentials everywhere else: an enterprise PKI issuing short-lived certificates, mutual TLS between workloads, service identity derived from where the workload actually runs rather than from a secret somebody pasted into a config map years ago.

Read the assertion and notice what is not in it

The projected token carries an issuer, an audience, a namespace, a service account, an expiry. It carries no human being.

It cannot answer whether the analyst is allowed to read payroll, because that fact was never encoded in it and never could be. Federation proves the caller is your gateway. It says nothing about the requester standing behind four layers of application code.

If your authorization story ends at federation, you have built a deputy that holds powerful credentials and serves anyone who can reach its port. That is not a hardening gap. It is the 1988 bug with better TLS.

Your gateway holds the credentials, so it owns the authorization decision

The chain that actually holds has five links:

  • User authentication at the edge against your identity provider, at the assurance level the data class demands.
  • Identity propagation into the agent runtime, so the subject, groups and tenant survive the hop instead of being dropped at the first internal call.
  • A scoped task token minted per request, naming the user, one action, one resource class, and an expiry measured in minutes.
  • Gateway enforcement, where a policy decision point answers one question: is this user allowed to do this on this data, right now.
  • Gateway to provider over federation, then inference.

Link four is the one teams skip, and they skip it for an understandable reason. Links one through three feel like authorization. You authenticated someone, you carried their identity, you minted a narrow token. It looks finished.

It is not finished, because nothing in links one through three ever said no.

A task token trimmed to the claims that matter:

{
"iss": "https://idp.internal/agent-runtime",
"aud": "inference-gateway",
"sub": "u-48213",
"groups": ["reporting-analysts", "all-staff"],
"act": { "sub": "spiffe://platform/ns/agents/sa/assistant" },
"task": {
"action": "summarize",
"resource_class": "shared-drive-document",
"resource_ref": "doc:9f12c0",
"exp_seconds": 120
},
"exp": 1780000120
}

The act claim is the honest part. It records that a workload is acting on behalf of a subject, which means the gateway can no longer pretend the workload is the subject. Two identities, both present, neither substituting for the other.

The token is the cheap part

Minting that token takes an afternoon. Deciding on it is the engineering.

A gateway policy that evaluates both identities:

package ai.gateway.authz
import future.keywords.if
import future.keywords.in
default decision := {"allow": false, "reason": "no matching entitlement"}
decision := {"allow": true, "reason": "entitlement match", "policy": policy_version} if {
some grant in data.entitlements[input.subject.sub]
grant.action == input.task.action
grant.resource_class == input.task.resource_class
input.task.exp_seconds <= 300
}
decision := {"allow": false, "reason": "task token exceeds max lifetime", "policy": policy_version} if {
input.task.exp_seconds > 300
}
policy_version := "2026-06-02.1"

Hold on to that data.entitlements lookup. It is the thing I got wrong, and I will come back to it.

Authorization in the caller is a convention, and the second caller always arrives

Our first gateway did rate limiting, token accounting and audit. Authorization lived in the chat application, which checked entitlements before calling out. Defensible design, cleanly reviewed, shipped.

Four months later a second team integrated against the gateway directly. Batch summarization for a reporting workflow, legitimate business need, approved network path, their own service account.

They inherited none of the entitlement checks, because those checks lived in an application they had never opened.

We caught it during a tabletop exercise rather than in production, and I am clear-eyed about how much of that was luck rather than design. The structural lesson is the one worth carrying: if a control lives in one caller, it is not a control. It is a convention, and conventions expire the moment somebody finds a second way in.

A semantic cache keyed on the prompt is an authorization bypass with a hit rate

The second failure still irritates me, because the design review had three experienced people in it and we all nodded.

We put a semantic cache in front of the model to cut cost and latency. The key was a hash of the normalized prompt plus the model identifier. Hit rates in the high teens on repeated analytical questions, which on this workload is real money (illustrative — the production figures are not mine to publish).

Identity was not in the key.

Two users asking a similar question about a document only one of them could read would be served the same cached answer. The retrieval step that would have enforced anything was never reached, because the cache answered first and the cache does not know what a clearance is. We found it by reading the cache implementation during a security review, not by a test, and not before it had run against non-regulated data for weeks.

Any cache in front of inference must be keyed by the authorization context, not the prompt. If that collapses your hit rate, the hit rate was quietly telling you how many of your users should not have been able to ask that question.

Content filters are a detective control wearing a preventive control’s clothes

The seductive fix is an interceptor. Scan the prompt, scan the response, block anything that looks like compensation data.

I have watched three teams reach for this and I will keep arguing against it. It fails on paraphrase. It fails on aggregation, where no individual chunk is sensitive and the summary is. It fails on the examiner’s actual question, because “the filter did not match” is not an authorization decision and cannot be mapped to a control.

Put the decision where it can be recorded with the inputs that produced it. Our gateway emits one record per call: subject, groups as resolved at decision time, action, resource class, policy version, allow or deny. That is the same evidence discipline we already apply to Kubernetes admission control, aimed at a new control point.

Pulling the deny record for a specific call:

aws logs filter-log-events \
--log-group-name /platform/ai-gateway/decisions \
--filter-pattern '{ $.subject = "u-48213" && $.allow = false }' \
--start-time "$(date -d '24 hours ago' +%s)000" \
| jq -r '.events[].message | fromjson
| [.ts, .subject, .task.action, .task.resource_class, .reason, .policy] | @tsv'

If you cannot produce that line for an arbitrary request from last Tuesday, you do not have an authorization layer. You have logging.

Mirroring entitlements into policy was my mistake; fetch as the user instead

Back to data.entitlements.

We replicated entitlement facts into the policy layer so the gateway could decide without a network round trip. Fast, deterministic, easy to test. Also stale by Thursday. Somebody changes teams, a document gets reclassified, a group gets nested inside another group, and your policy data is confidently, auditably wrong.

The design I would build from day one: the retrieval path fetches as the user. Exchange the task token for a downstream credential bound to that subject and let the system of record refuse. The gateway still decides whether the action is permitted and still writes the decision record. The data store decides whether this person may read this object, because it is the only component that actually knows.

Two decisions, each made by the thing that holds the truth. It costs latency on every retrieval. It survives reorganizations, which replicated ACLs do not.

The weaker version of this argument is that you should do both. I used to say that. I no longer do, because every team that holds a mirrored copy eventually trusts the copy under deadline pressure, and then the round trip exists only on the paths nobody optimized.

Federation is table stakes; the open question is whether anything in your stack knows who is asking

The confused deputy was never fixed. It was managed, by engineers who learned to distrust privileged intermediaries that follow instructions well.

Agents are the most instruction-following intermediaries we have ever put into production, and we have handed them credentials that read across the enterprise. They will do precisely what they are asked, with whatever authority is available to them, and they will leave a clean audit trail while doing it.

So federate the workloads. Delete the static keys. Then go and trace one real request end to end and find out which component, if any, could have said no to the analyst.

In most architectures I review, the answer is none of them, and the diagram does not show it.

Beyond the API Key: Workload Identity Federation for AI Agents

We had migrated our agent workloads off static API keys. Federation configured, issuer registered, rules scoped, documentation updated, ticket closed.

Then someone ran ant auth status inside a pod and found the static API key was still the credential winning every request.

A leftover ANTHROPIC_API_KEY sat in the container environment, and it sits above federation in the SDK’s credential precedence chain. Federation was configured correctly and doing nothing. For weeks the security posture we had documented and the one we had deployed were not the same thing.

That gap is the subject of this post, because it recurs at every layer of agent identity — most seriously at the layer almost nobody is designing for.

Federation does not remove the trust decision, it relocates it

The pitch for Workload Identity Federation is simple enough to be misleading. Your workload presents a short-lived OIDC JWT from an identity provider you already run. The model provider validates it against trust rules you configured and returns a short-lived access token bound to a service account in your organization. No static secret to mint, store, rotate, or leak.

That is a real improvement. A credential that expires in minutes beats one that expires never, and taking secrets out of CI removes a whole class of incident.

But notice what happened to the trust decision. It did not disappear. It moved from “who holds this string” to “which claims, from which issuer, may act as which principal.” That is a policy you now own and can get wrong — and getting it wrong is quieter than leaking a key, because nothing looks broken.

Three resources, and only one of them is obvious

The trust model is a triple. A service account is the principal the minted token acts as. A federation issuer registers the OIDC provider whose signatures you accept. A federation rule is the bridge: when a JWT from this issuer carries claims that look like this, mint a token for that service account, with this scope and lifetime.

The rule is where the security lives, and the rule is the one people under-specify.

A correctly narrow rule for a Kubernetes workload:

{
"name": "agent-orchestrator-prod",
"issuer_id": "fdis_...",
"match": {
"subject_prefix": "system:serviceaccount:agents:orchestrator",
"audience": "https://api.anthropic.com"
},
"target": {
"type": "service_account",
"service_account_id": "svac_..."
},
"workspace_id": "wrkspc_...",
"oauth_scope": "workspace:inference",
"token_lifetime_seconds": 600
}

Two fields here are load-bearing and easy to get lazy about.

subject_prefix with no trailing wildcard pins one service account in one namespace. Write system:serviceaccount:agents:* and every pod in that namespace can mint a token as this principal. Write system:serviceaccount:* and every pod in the cluster can.

audience is subtler. Omit it and the rule accepts the cluster’s default-audience service account tokens — which every pod already has projected into it, with no configuration at all. A broad subject with no audience matcher is not a weak control. It is an open door that passes review because it looks like configuration.

workspace:inference rather than workspace:developer is the other choice I would defend. An agent that only calls the model does not need Files or Skills management. The scope is a ceiling on the minted token, and ceilings are cheap.

Your cluster is already an OIDC issuer, which cuts both ways

Every Kubernetes cluster signs OIDC JWTs for its pods through projected service account tokens. The API server is the issuer. Each token’s sub is system:serviceaccount:<namespace>:<name>. You do not have to deploy anything to get a workload identity — you already have one.

The complication on-premises is reachability. Our internal clusters use issuer URLs that resolve nowhere on the public internet, so discovery mode cannot work. That leaves inline JWKS: you paste the cluster’s public keys into the issuer registration and the provider never dials out.

Which brings me to the second thing that broke.

Inline JWKS has no automatic key refresh. A cluster upgrade rotated the service account signing key, the registered JWKS went stale, and every exchange from that cluster started failing signature verification. Nobody connected a planned Kubernetes upgrade to an authentication outage in a system that had not been touched.

Recovery took two minutes once we understood it: re-fetch the keys, update the issuer. Finding it took far longer, because the failure surfaces as a 401 reading Authentication failed and nothing else.

That opacity is deliberate and correct: a distinguishable error lets an attacker map your rules by watching which failures differ. The operational cost is that your debugging signal lives in the provider’s authentication history rather than your own logs. Learn that before an incident.

The lesson I would generalise: any trust anchor you pin by value instead of by reference becomes a dependency of your upgrade process. Put it in the runbook for the upgrade, not in the runbook for the outage.

The token lifetime is not the number you configured

A detail that cost us a confusing afternoon.

The minted token’s lifetime is the lesser of the rule’s token_lifetime_seconds and twice the remaining lifetime of the JWT you presented, with a 60-second floor. The second bound exists so a derived token cannot meaningfully outlive the identity it came from.

Read that again if you build token caches. Not twice the JWT’s configured lifetime — twice what is left on it. Present a projected token late in its rotation window and you get a short access token, and code that assumed the configured TTL will keep using a credential that expired minutes ago.

The SDK handles this: a two-tier refresh, advisory at expiry minus 120 seconds and mandatory at minus 30, re-reading the token file on every exchange so rotated projected tokens are picked up transparently.

Which is the argument against writing your own exchange loop. We wrote one anyway, for a service that predated SDK support, and got the arithmetic wrong in exactly that way.

Replay protection will find your retry loop

The third failure. An assertion carrying a jti claim can be exchanged only once per issuer.

Our custom exchange code had a retry wrapper around the token request. On a transient network error it retried — with the same cached JWT. The second attempt was rejected as a replay, the retry logic saw another failure and tried again, and the whole thing degraded into a loop that could never succeed.

Single-use assertions are the right default. But they invert a reflex: retry means mint a fresh assertion and exchange again, not send that again. Every retry path around a token exchange has to re-acquire its input, and an idempotent-looking call that is not idempotent deserves a comment in the code.

SPIFFE gives you an identity that survives leaving the cluster

Projected service account tokens are fine while the issuer is the cluster. Once an agent’s identity has to mean something across clusters, across data centres, and to workloads that aren’t pods, you want SPIFFE. With SPIRE the OIDC Discovery Provider becomes the federation issuer, and identity is a SPIFFE ID attested from node and workload selectors rather than a service account name.

The delivery path is where this goes wrong. The SDK reads its identity token from a file; SPIRE hands out JWT-SVIDs over the Workload API socket. Bridge the two by writing the SVID to a volume and that volume matters:

volumes:
- name: spire-agent-socket
csi:
driver: csi.spiffe.io
readOnly: true
# The JWT-SVID is a bearer credential. Memory-backed emptyDir keeps it
# off node disk, out of volume snapshots, and out of node backups.
- name: svid-token
emptyDir:
medium: Memory
sizeLimit: 1Mi

medium: Memory is the whole point of that block. A default emptyDir is a directory on the node’s filesystem, so your short-lived credential lands on a disk that gets snapshotted and backed up — and it will outlive the pod in at least one of those places.

Short-lived credentials are only short-lived where they live. We found a non-memory-backed volume in a chart we had inherited, which is the kind of finding that makes you grep every chart you run.

Federation authenticates your platform, not your users

Everything above is plumbing. This is the section I would keep if I had to cut the rest.

Workload Identity Federation solves authentication between your platform and the model provider. It says nothing about authorization between your users and your agents, and those are not the same problem.

Here is the shape of it. Your agent runs as a service account. A federated token authenticates it to the provider. A user asks the agent to do something, the agent calls tools on the user’s behalf — and at the tool boundary, every request carries the agent’s identity, not the user’s.

The agent is now a deputy holding more authority than any of its callers, acting on instructions from all of them. That is the confused deputy problem, described in 1988, and agent architectures reconstruct it faithfully.

The consequences are immediate. You cannot attribute an action to a user, because the audit trail shows a service account. You cannot enforce per-user entitlements, because the tool only knows the agent. And an instruction injected into context executes with the agent’s authority rather than the authority of whoever injected it.

You need a second identity travelling with the request, and a policy decision at the tool boundary that weighs both:

package agent.tooling
default allow := false
# Both principals must be present. An agent-only request is not a
# request on anyone's behalf, so refuse to treat it as one.
allow if {
input.agent.service_account == "svac_agent_orchestrator"
input.tool in data.agents.orchestrator.allowed_tools
user_permits
}
# Authority is the intersection, never the union. The agent cannot do
# what the user cannot do, and vice versa.
user_permits if {
some entitlement in data.users[input.user.subject].entitlements
entitlement == data.tools[input.tool].required_entitlement
}
# Context-derived instructions get the caller's authority, not the
# agent's. Anything originating from retrieved content is read-only.
deny contains "escalation_via_context" if {
input.request.origin == "retrieved_content"
data.tools[input.tool].mutating
}

The rule that matters is user_permits. Effective authority is the intersection of what the agent may do and what the user may do. Most agent deployments I have reviewed compute the union by accident, because the agent’s credential is the only one that reaches the tool.

The standards work is converging here. The IETF AIMS effort and the agent authorization draft treat the agent as a principal distinct from both the user and the deploying organization, with delegation stated explicitly rather than inferred from whichever credential happened to be in the request. WIMSE covers the workload half. Neither is finished. Both are worth reading first.

What I’d do differently

I would build the user-authority boundary before shipping the agent, not after.

We sequenced it the other way, because federation is a completable task with a checkmark at the end and per-user delegation is an architecture problem touching every tool. So we shipped authentication, planned authorization, and ran for a while with an agent whose effective permissions were its own. Nothing was exploited. The audit gap was real, and threading a second principal through tool interfaces already in use cost multiples of designing it in.

I would also treat the migration off API keys as unfinished until a command inside the running workload proves which credential won. “Federation is configured” and “federation is in use” are different claims, and only one of them is verifiable from a config file.

An agent holding your platform’s identity is a deputy waiting to be confused

Replacing static API keys with federated short-lived tokens is worth doing, and it is the easy half. The hard half is that an agent is not a workload. A workload acts for itself; an agent acts for someone, and usually for many someones with different entitlements.

If your answer to “what may this agent do?” is a single service account scope, you have not answered the question. You have answered a different one, convincingly enough that it may be a while before anyone notices.

What 350 Fault Injection Experiments Taught Us About Kubernetes Resilience

Roughly half our fault injection experiments found a real failure.

That number should bother you, because every one of those systems had already passed design review, load testing, and a disaster recovery exercise. We ran about 34 experiment categories, built over 100 templates, and executed more than 350 experiments against a platform that clears around $4.5 trillion a day across multi-region Kubernetes and Kafka on AWS and our own data centres, under a 99.99% availability target and Regulation SCI.

A roughly 50% hit rate against a system that had passed every conventional gate is the single most useful data point I have about the limits of conventional gates.

Resilience reviews measure documents, not systems

Here is what I think the industry gets wrong about resilience.

We verify it by asking people to describe what would happen. An architect writes that the service tolerates the loss of an availability zone. A reviewer reads it, finds the reasoning sound, and signs. Both parties are now confident about a behaviour neither has observed.

The reasoning usually is sound. The failures we found almost never came from a wrong design. They came from the gap between a design and its configuration — a probe timeout nobody revisited, a disruption budget written when the replica count was different, a client library default from three upgrades ago.

You cannot review your way to that. You have to break something.

The blast radius problem comes before the chaos problem

The hard part of fault injection in a regulated environment is not the faults. It is proving to a risk committee that the tooling which deliberately breaks production cannot break more than you said it would.

So the security model was the first design, not a later hardening pass.

A single orchestrator account. All experiment definitions live in one account that owns no workloads. Nothing to accidentally target locally, one audit surface, one place where the catalogue of what we are permitted to break is version-controlled.

Scoped cross-account roles. The orchestrator assumes a role in each target account. That role’s permissions are the narrow set of actions a given experiment category needs, and nothing else.

Tag-based targeting exclusively. No resource ARNs in templates. An experiment declares the tags it acts on, and IAM independently refuses the action unless the resource carries them.

The IAM condition doing the real work:

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FaultInjectOnlyTaggedTargets",
"Effect": "Allow",
"Action": ["ec2:StopInstances", "ec2:RebootInstances"],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:ResourceTag/fis-eligible": "true",
"aws:ResourceTag/fis-scope": "${aws:PrincipalTag/experiment-scope}"
}
}
}
]
}

The second condition key is the one I would point a reviewer at. Matching fis-eligible alone means every opted-in resource in the account is reachable by every experiment. Binding fis-scope on the resource to a principal tag on the assumed role means an experiment scoped to one service physically cannot act on another, regardless of what the template says.

Defence in depth here is not ceremony. The template is a statement of intent; IAM is the thing that holds when the intent is wrong.

Tag propagation quietly widens your blast radius

We learned the value of that second condition the direct way.

An early network experiment targeted a tag applied at the Auto Scaling group level. ASG tags propagate to launched instances by default, and the same tag had been applied to a second ASG months earlier by someone standardising their Terraform. The experiment matched both. We injected packet loss into a service that was not in the experiment plan, during that service’s business hours.

Nothing failed over incorrectly, which was luck rather than design. The on-call engineer for that service spent 40 minutes investigating a degradation whose cause was sitting in a different team’s experiment window, and found out what had happened from us rather than from his own telemetry.

Two things came out of it. The IAM scope binding above, so a mis-scoped tag cannot translate into a mis-scoped action. And a pre-flight resolver that enumerates the exact resources a template will touch and requires a human to confirm the count before execution.

Tag-based targeting is the right primitive. It is also a query, and a query against a tag space your whole organisation can write to will eventually return something you did not expect.

Reaching on-prem without opening an inbound path

Half our platform is not in AWS. Resilience testing that stops at the cloud boundary tests the easy half, and the interesting failures in a hybrid system live exactly at that seam.

SSM hybrid activations let us treat on-premises and DMZ hosts as managed instances. The agent establishes an outbound HTTPS connection and polls. No inbound firewall rule, no listener on a DMZ host, no new path through the perimeter — which is what made the conversation with the network security team short.

Registering a DMZ host, with the constraint that matters:

# Activation is short-lived and bounded by instance count. A long-lived,
# high-count activation code is a credential; treat it like one.
aws ssm create-activation \
--default-instance-name "dmz-fis-target" \
--iam-role "SSMHybridFaultInjectionRole" \
--registration-limit 1 \
--expiration-date "$(date -u -d '+2 hours' +%Y-%m-%dT%H:%M:%S)" \
--tags "Key=fis-eligible,Value=true" "Key=fis-scope,Value=edge-tier"

--registration-limit 1 and the two-hour expiry are there because our first pass used a generous limit and a 30-day window, which is what the examples show. That activation code is a bearer credential that can enrol an arbitrary host into your managed fleet. Ours sat in a pipeline variable, reusable, for a month.

Nothing came of it. It was still the worst finding of the programme, and it was self-inflicted — an artifact of copying an example without asking what the parameters were protecting.

The five failure classes that actually taught us something

Across 34 categories, the results clustered into five groups. Memory and CPU constraints, network latency and packet loss, node disconnection and recovery, EBS volume failure, and cascading failure.

The first four are table stakes. The fifth is why you do this.

Memory and CPU pressure mostly finds your probe configuration

Memory constraint experiments rarely killed the thing we were pressuring. They killed its neighbours.

Under memory pressure the kubelet evicts by QoS class, and we found pods we considered important running as Burstable because someone had set requests without limits in a Helm values file. The experiment was scoped at one workload; the eviction landed on another in the same node pool.

CPU constraint found the same bug from a different direction. Throttle a service enough and its liveness probe times out, so Kubernetes restarts a container that was slow rather than broken. Several of our probe timeouts were tighter than the service’s own p99 under load, which means we had configured Kubernetes to amplify latency into outage.

A liveness probe is a statement that a slow process is better killed than waited for. Most of ours were set by copying an example, and most should have been readiness probes.

Latency injection breaks consumer groups, not producers

Our most interesting result came from injecting a few hundred milliseconds of latency on a Kafka path.

Producers absorbed it. Consumers did not — the added latency pushed processing past the configured max.poll.interval.ms, the broker considered those consumers dead, and the group rebalanced. Rebalancing paused consumption, which deepened the lag, which triggered the next rebalance. The system had found a stable oscillation and would not have recovered on its own.

No individual component was misconfigured. The timeout was a default, the latency was realistic for a degraded link, and the interaction between them had never been exercised. That is a failure mode that exists only in composition, and it is unreachable by component-level testing.

Volume failure is where recovery time hides

EBS failure experiments against stateful workloads produced our longest recovery, around six hours.

The attach/detach cycle did not complete cleanly, the volume stayed in a transitional state, and the pod’s rescheduling blocked on a six-minute multi-attach timeout that then retried against a volume still held by a node the scheduler believed was gone. Our runbook for the scenario was accurate and useless, because it assumed the API would return the volume state the runbook described.

Recovery procedures that have never been executed are hypotheses. We now rate a runbook’s maturity by when it was last run against an injected fault, not by when it was last reviewed.

Half the passing experiments were not testing anything

The finding that changed how I think about chaos engineering is an embarrassing one.

When we audited the templates after the first large batch, a meaningful share of experiments that had “passed” had never established that the fault landed. No steady-state hypothesis, no stop condition, no assertion — just an action, a wait, and a green result. An experiment that silently failed to inject anything looked exactly like a system that tolerated the fault perfectly.

We had been generating confidence, not evidence.

The gate we should have had from the beginning:

{
"stopConditions": [
{
"source": "aws:cloudwatch:alarm",
"value": "arn:aws:cloudwatch:us-east-1:111122223333:alarm:clearing-tier-slo-burn"
}
],
"experimentOptions": {
"accountTargeting": "single-account",
"emptyTargetResolutionMode": "fail"
},
"logConfiguration": {
"cloudWatchLogsConfiguration": {
"logGroupArn": "arn:aws:logs:us-east-1:111122223333:log-group:/fis/experiments:*"
},
"logSchemaVersion": 2
}
}

emptyTargetResolutionMode: fail is the setting I wish I had found on day one. The default is to proceed when target resolution returns nothing, which is how an experiment against a renamed tag reports success while doing nothing at all.

The stop condition matters for a different reason. A stop condition is the only part of an experiment that proves you were watching, and a template without one is not an experiment. It is an outage with paperwork.

What I’d do differently

I would not have built 100 templates before building the scoreboard.

We optimised for coverage — categories enumerated, templates authored, a satisfying number to report. Then we audited and discovered a portion of that coverage was decorative. Ten templates with mandatory hypotheses and automated verdicts would have taught us more in the first quarter than 100 templates did, because the thing you are building is not a library of faults. It is a feedback loop, and an unverified experiment poisons the loop while inflating the metric.

Concretely: no template enters the catalogue without a declared steady-state hypothesis, an automated pass/fail assertion, and a stop condition. Volume second.

I would also have run the pre-flight target resolver before the first experiment rather than after the one that strayed. It took about a week to build. It would have prevented a 40-minute investigation by an engineer who had no idea his service was in anyone’s test plan, and I had enough information to predict that failure before it happened.

Every passing experiment is a claim about your observability

The real output of 350 experiments was not a list of fixed bugs. It was a calibration.

We now know which of our alerts fire on injected faults and which stay silent. We know which runbooks survive execution. We know, for a set of specific failures, the difference between what the design documents claim and what the system does — and that difference was large enough, often enough, that I no longer trust a resilience assertion that has not been executed.

If you take one thing from this: treat an experiment with no stop condition and no hypothesis as a failed experiment, not a passing one. Half of ours were, and we were counting them as wins.