Green Isn't Proof

The stack was green and no prompt worked

Every container reported healthy. Every dashboard was green. Nothing could answer a single question. Health checks proved process availability, and nobody had tested the contract between the processes.

Published
Evidence state
Historical incident on my own infrastructure, generalized

What I observed

Every service in an AI stack reported healthy. Container health checks passed. The orchestrator showed the full set running. Restart counts were zero. The monitoring dashboard was uniformly green, and had been for some time.

Not one prompt returned an answer.

There was no error banner, no crash loop, no resource exhaustion, and nothing in the logs that announced itself as a failure. The system was, by every signal I had built to watch it, working perfectly.

What the obvious explanation suggested

When everything is green and nothing works, the first instinct is to distrust the part you can see least. So I went looking at the model backend: was it actually loaded, was it out of memory, was inference silently timing out?

That is a reasonable place to look, and it was wrong, in an instructive way. The backend was fine. It answered when I asked it directly.

So was every other component, individually. Each one, tested on its own, behaved exactly as designed.

Why that was incomplete

The chain looked like this — synthetic, but structurally what matters:

  user
    │
    ▼
  chat interface ──► gateway / router ──► model backend
       health: ok        health: ok           health: ok

Three components, three health checks, three green lights. And here is what each health check actually asserted:

Component What its health check proved What it did not prove
Chat interface The web process was up and serving That the model name it requests exists anywhere downstream
Gateway / router The process was up and its config parsed That any configured route resolves to a reachable, working backend
Model backend The process was up and had loaded a model That the name it publishes matches the name anything upstream asks for

Every check was a liveness check. Liveness is a statement about one process: am I running, did I bind my port, does my own status endpoint return success.

Not one of them was a contract check — a statement about the boundary between two processes: when I ask my neighbour for the thing I will actually ask it for in production, do I get back the thing I expect?

The failure lived entirely in those boundaries. The identifier the interface requested did not resolve to anything the router could route. The router returned an error the interface rendered as an empty response rather than a failure. Nothing in that path was unhealthy. The path simply did not exist, and no check had ever been asked to look at a path.

This is what makes the class dangerous: the number of things that can be individually healthy while collectively useless grows with every component you add. Adding more health checks of the same kind does not help. It makes the dashboard greener.

What I changed

One synthetic end-to-end request became the real health check. A single scripted transaction that enters where a user enters, traverses every hop, and asserts on the content of what comes back — not the status code. If a real question does not produce a real answer, the system is down, regardless of what the component checks say.

This is the whole fix, and it is deliberately unsophisticated. It costs one inference per interval. It replaces a dashboard that could not fail with one that fails when the product fails.

Identifier drift became a startup assertion. The names by which components refer to each other — model identifiers, route names, aliases — are a contract in the most literal sense, and they were being maintained by hand in more than one file. Now the deployment asserts at startup that every name one component requests resolves in the component that must serve it, and refuses to come up otherwise. A mismatch fails at deploy time, loudly, instead of at request time, silently.

Empty responses stopped being rendered as success. A downstream error that arrives as an empty body is a failure, and the interface now says so. This was the reason the outage was invisible rather than merely present: the one place a human was actually looking had been designed to degrade quietly.

Health check semantics got named. Every check in the stack is now explicitly one of three things — liveness (the process is running), readiness (this component can serve requests), or contract (the boundary to my dependency works end to end). Written down, the gap was obvious: there had been eleven of the first kind and none of the third.

The positive and negative tests

A fix that is only ever tested when it passes is not tested.

Only the negative test proves the check can detect anything. A monitor that has never been observed to fail is not a monitor; it is a decoration that happens to be green. Running that exercise is what turned "we added a check" into "we know what this check catches."

What this did not prove

It did not prove the stack is correct. It proves one representative path works, which is a much smaller claim, and deliberately so. Other paths, other identifiers, and other request shapes remain untested unless someone tests them.

It also did not prove that per-component health checks are useless. They are good at what they do: telling you which component to look at once you already know something is broken. The mistake was letting them answer a question they were never able to answer — is the system working?

What it means for a buyer

This one translates almost too neatly.

An AI system's components can each be operating exactly as specified while the system delivers nothing. If your assurance that a system is working comes from a dashboard of component statuses, you have assurance that the components are running. That is not the same as assurance that anyone can get an answer, and the gap between those two claims is invisible until someone tries.

The question worth asking a vendor, an integrator, or your own team is not "is the system monitored?" It is: when did a real request last traverse the whole path and return a correct answer, and what would have gone red if it hadn't?

If the answer is a dashboard, the answer is that nobody knows.

What to do differently tomorrow

Further reading

Three commentaries

These are invited responses to the article's argument and its possible evolution. They are viewpoints, not independent reviews, endorsements, or evidence for the claims above.

Codex

The liveness, readiness, and contract distinction generalizes well beyond AI systems: a stack can be fully alive and still fail the one workflow that matters. The next step is to maintain a small set of synthetic contract probes, one per consequential boundary, and periodically prove that a deliberately broken dependency makes the right probe fail for the right reason.

Qwen, local model

The article correctly identifies that liveness checks create a false sense of security by ignoring inter-process contracts. However, it underestimates the fragility of relying on a single synthetic transaction for complex AI stacks. The idea should evolve toward dynamic, multi-path contract testing that validates diverse query types, ensuring the system handles edge cases rather than just one representative request, thereby reducing the risk of silent, partial failures.

Claude

Naming checks as liveness, readiness, or contract is the transferable move; eleven of the first kind and none of the third is the sentence that lands. One synthetic transaction is a real fix and a narrow one—a single probe can quietly become the only path anyone maintains. I'd grow it to one contract check per boundary and re-run the deliberate-break exercise on a schedule.


If this overlaps with something you are working on

Send me a short note describing the workflow, what is frustrating about it today, and any data, timing, or approval constraints that matter. Start a conversation.

How I use AI in my writing