# A good fallback hides the failure it was built for

> Source: <https://dev.to/znlong2203/a-good-fallback-hides-the-failure-it-was-built-for-1cgm>
> Published: 2026-08-29 19:22:30+00:00

Built for Google's All Things Agentic Hackathon (Devpost), August 2026.

I spent three weeks building a fleet of five agents that coordinate home care

admin — reading photographs of pill bottles, voice memos from carers, insurance

letters — and stopping to ask a human before anything irreversible.

The tests passed. The health endpoint was green. Every screen rendered. And for

most of those three weeks, three separate things were broken in ways that

produced no error, no warning, and no visible symptom at all.

They were all hidden by the same thing: a fallback doing exactly what I designed

it to do.

The UI had never once been live

The web interface reads from the deployed API and falls back to a committed

fixture corpus when a request fails. It labels itself honestly — a small badge in

the corner reads sample data when it is showing fixtures, and live when it

is not. I was proud of that badge. A demo that silently shows canned data while

implying it is live is a lie the viewer cannot detect.

The badge worked perfectly. It said "sample data" for the entire life of the

deployment, and I never noticed, because I was testing the API with curl and it

answered every time.

The cause was four characters of shell. gcloud run deploy --source passes

--set-build-env-vars to the builder. A Dockerfile build does not receive them

as build args. So NEXT_PUBLIC_VIGIL_KEY was empty in every image ever shipped,

every browser request was unauthenticated, every request failed, and every screen

fell back — correctly, quietly, exactly as specified.

Then there was a second layer. The UI and the API are served from the same

container, so the deploy script deliberately leaves the API base URL empty:

requests are same-origin and need no host. But the code read an empty base URL as

"no API configured" and short-circuited to the fixture before attempting a

single request. The one deployment shape the system was designed for was the one

shape it refused to try.

An agent's proposals never reached the approvals queue

The medication agent can propose a schedule change. Every clinical change goes to

a human — that is the entire safety argument of the project. The tool wrote a

document into a proposals collection.

The API served the approvals screen from a collection called approvals.

Two mechanisms for one idea, running past each other, and nothing in between to

notice. The Approvals screen looked populated and correct the whole time —

because it had sample cards to render.

Voice notes never reached the agent

Uploaded .wav files were stored as application/octet-stream. The upload

handler validated the file extension against an allowlist and then preferred the

content type the client declared over the allowlist it had just consulted. Both

curl and browsers send application/octet-stream for .wav.

Downstream, the pipeline decides whether to attach a binary to the agent's message

by looking at that content type. It saw neither image/ nor audio/, so it

attached nothing. The agent received a prompt with no recording in it and reported

that the audio format was unsupported — which was true of what it had been given,

and false of the file. The screen faithfully reported the agent. Every component

did what it was told.

What exposed all three

One instruction from the person I was building for: delete the sample data.

Every screen shows the deployed system, or says plainly that it cannot reach it.

All three surfaced within an hour.

That is not a coincidence, and it is the part worth keeping. A fallback is a

second, quieter implementation of your feature. When the first one breaks, the

second one carries on and the system looks healthy — which is precisely what you

asked it to do. The more graceful the degradation, the longer the outage hides.

I still think the fallback was a good design. What I would change is that a

fallback should be loud in the environment where it should never happen. In

development, fall back silently. In the deployment where a backend is definitionally

present, falling back is not degradation — it is an incident, and it should say so.

The one that had nothing to do with fallbacks

While fixing those, I found a fourth, and it is my favourite.

Five tool signatures took a run_id: str parameter. The agent framework builds a

tool's declaration from its signature, which means every parameter is a field the

model fills in. So the model filled it in — with "run-001", a plausible,

well-formatted identifier belonging to no run that has ever existed.

Every approval created that way was untraceable. The card in the queue named a run

that does not exist. The audit trail could not be correlated. And the idempotency

key was namespaced under the hallucinated id, so two proposals from genuinely

different runs could collide and one would be silently suppressed as a duplicate.

A tool signature should carry what the model needs to decide, and nothing else.

Bookkeeping the model cannot know is bookkeeping the model will invent. The run id

now comes from a context variable, and a test asserts that no tool signature

contains a runtime-only field:

RUNTIME_ONLY = {"run_id", "trace_id", "actor", "agent", "step_id"}

def test_no_tool_asks_the_model_for_runtime_bookkeeping():

offenders = {

f.**name**: sorted(RUNTIME_ONLY & set(signature(f).parameters))

for f in ALL_TOOLS

if RUNTIME_ONLY & set(signature(f).parameters)

}

assert not offenders

And the one where I destroyed the evidence myself

The project's centrepiece is an eval gate with an anti-gaming judge: an agent

proposes a rewrite of its own instruction, the proposal is scored against a fixed

suite, and a second model argues that the improvement is not real. The stored

record of one agent gaming its own test and being caught is the most valuable

artefact the system produces.

I ran a second, honest improvement round — no gamed candidate — because a gate

that has only ever said no is hard to distinguish from a gate wired to say no.

It was rejected for an ordinary reason: the proposer truncated the instruction

mid-sentence and the score fell.

And it overwrote the first record.

Version records were keyed on the proposed version number. The bump function is

deterministic, so every rejected proposal from 1.4.2 is named 1.5.0-rc. The

second set() replaced the first. Nothing reported a loss, because overwriting is

what set() is for.

The docstring on that function reads: "Rejections are kept deliberately… a gate

whose refusals are not retained cannot be audited." It was true of the intent and

false of the storage. Records are now one document per attempt, and the registry

holds both verdicts — one rejection for gaming, one for quality. The pair is

better evidence than either alone.

The last one: a refusal that was really a crash

The newest feature in the system is the one I was most nervous about. A discharge

letter says to rest the leg on a cushion so the ankle sits higher than the hip,

knee slightly bent. That is correct, a clinician wrote it, and it is delivered on

the worst possible medium — a sheet of A4 handed over once at a hospital desk to

a relative too worried to take it in. A week later it is being done slightly

wrong and nobody knows.

Video is the only format that shows a movement, so the fleet proposes filming

it, a human approves, and Veo renders the action. The clinician's sentence is

quoted above the clip; the numbers stay in the text, because generated lettering

is unreliable and a wrong figure on a care video is worse than no video.

The card can also say the instruction was not filmed — some instructions have

no picture in them. "The district nurse visits on Tuesdays" is a fact, and a

pleasant generated shot of a calendar attached to medical paperwork reads as

evidence. Refusing is the correct behaviour and the card shows the reason.

The first live run rendered nothing, and the reason shown to the user was:

Expecting ',' delimiter: line 6 column 16 (char 583)

I had asked the model for JSON in prose and parsed the reply. It wrote a scene

containing a quotation mark, and the response stopped being JSON. Fine — except

for where that string went. The record is keyed by the approval id so the render

never runs twice, which meant a transient fault had been written into a

permanent "not filmed" card, with a parser error where a carer would look for the

reason, and nothing left in the system that would ever try again.

Two fixes, and the second is the one I keep thinking about. The schema is now

declared to the serving layer instead of described in a prompt — the same lesson

every agent in this codebase was already built on, which I had failed to apply to

the one call that was not an agent. And a fault and a refusal are now different

things in the type: a refusal is a decision and it is final; an error means try

again later, and it deliberately leaves nothing behind.

They look identical on screen. That is exactly why they must not be stored alike.

Refusing to help is not the same as being safe

The shared rule every agent in this fleet reads used to say "you do not give

medical advice". It was tidy, it was easy to defend, and it meant the part of

the folder the family most struggled with was the part the system would not

touch — while it went on cheerfully filing their insurance paperwork.

The rule is now precise instead of broad: carrying a clinician's words is

allowed, authoring is not. The safety did not come from the silence. It comes

from the quotation, and from a scope owned by the clinical department, which the

policy engine sends to a named human at any confidence — including a confidence

of 1.0. Every clip on screen carries who approved it.

Broad rules feel safer to write. They are mostly a way of moving the risk

somewhere you cannot see it.

What I would tell myself three weeks ago

Delete the safety net once, on purpose, in the environment that matters. Not

permanently — just long enough to find out what it has been carrying.

A component that "did what it was told" is not exonerated. All three of the

first bugs had a blameless component chain. The failure was in the seams, and the

seams are where the tests were not.

Numbers in your README expire. I quoted an eval score of 0.67 → 0.92 in mine.

By the time I checked, the deployed record said 0.75 → 0.83. A judge with two

tabs open would have caught it. I now regenerate the numbers from the running

system before I claim them.

Built for Google's All Things Agentic Hackathon, August 2026. Stack: Google ADK,

Gemini 3.5 and 3.6 on Vertex AI, Gemma for redaction, Gemini TTS and Veo, Cloud

Run, Firestore, Pub/Sub, Cloud Trace. All data synthetic. The system writes no

care advice of its own — it carries a clinician's, quoted, and every clinical

action waits for a person.
