{"slug": "a-good-fallback-hides-the-failure-it-was-built-for", "title": "A good fallback hides the failure it was built for", "summary": "A developer who built a fleet of five agents for home care administration for Google's All Things Agentic Hackathon discovered that his system's fallback mechanisms masked three critical failures for three weeks. The issues—an empty API key, a short-circuit to fixtures, and a content-type mismatch—were hidden because the fallbacks worked as designed, and were only exposed when the developer deleted the sample data. The developer emphasizes that fallbacks should be loud in environments where they should never occur.", "body_md": "Built for Google's All Things Agentic Hackathon (Devpost), August 2026.\n\nI spent three weeks building a fleet of five agents that coordinate home care\n\nadmin — reading photographs of pill bottles, voice memos from carers, insurance\n\nletters — and stopping to ask a human before anything irreversible.\n\nThe tests passed. The health endpoint was green. Every screen rendered. And for\n\nmost of those three weeks, three separate things were broken in ways that\n\nproduced no error, no warning, and no visible symptom at all.\n\nThey were all hidden by the same thing: a fallback doing exactly what I designed\n\nit to do.\n\nThe UI had never once been live\n\nThe web interface reads from the deployed API and falls back to a committed\n\nfixture corpus when a request fails. It labels itself honestly — a small badge in\n\nthe corner reads sample data when it is showing fixtures, and live when it\n\nis not. I was proud of that badge. A demo that silently shows canned data while\n\nimplying it is live is a lie the viewer cannot detect.\n\nThe badge worked perfectly. It said \"sample data\" for the entire life of the\n\ndeployment, and I never noticed, because I was testing the API with curl and it\n\nanswered every time.\n\nThe cause was four characters of shell. gcloud run deploy --source passes\n\n--set-build-env-vars to the builder. A Dockerfile build does not receive them\n\nas build args. So NEXT_PUBLIC_VIGIL_KEY was empty in every image ever shipped,\n\nevery browser request was unauthenticated, every request failed, and every screen\n\nfell back — correctly, quietly, exactly as specified.\n\nThen there was a second layer. The UI and the API are served from the same\n\ncontainer, so the deploy script deliberately leaves the API base URL empty:\n\nrequests are same-origin and need no host. But the code read an empty base URL as\n\n\"no API configured\" and short-circuited to the fixture before attempting a\n\nsingle request. The one deployment shape the system was designed for was the one\n\nshape it refused to try.\n\nAn agent's proposals never reached the approvals queue\n\nThe medication agent can propose a schedule change. Every clinical change goes to\n\na human — that is the entire safety argument of the project. The tool wrote a\n\ndocument into a proposals collection.\n\nThe API served the approvals screen from a collection called approvals.\n\nTwo mechanisms for one idea, running past each other, and nothing in between to\n\nnotice. The Approvals screen looked populated and correct the whole time —\n\nbecause it had sample cards to render.\n\nVoice notes never reached the agent\n\nUploaded .wav files were stored as application/octet-stream. The upload\n\nhandler validated the file extension against an allowlist and then preferred the\n\ncontent type the client declared over the allowlist it had just consulted. Both\n\ncurl and browsers send application/octet-stream for .wav.\n\nDownstream, the pipeline decides whether to attach a binary to the agent's message\n\nby looking at that content type. It saw neither image/ nor audio/, so it\n\nattached nothing. The agent received a prompt with no recording in it and reported\n\nthat the audio format was unsupported — which was true of what it had been given,\n\nand false of the file. The screen faithfully reported the agent. Every component\n\ndid what it was told.\n\nWhat exposed all three\n\nOne instruction from the person I was building for: delete the sample data.\n\nEvery screen shows the deployed system, or says plainly that it cannot reach it.\n\nAll three surfaced within an hour.\n\nThat is not a coincidence, and it is the part worth keeping. A fallback is a\n\nsecond, quieter implementation of your feature. When the first one breaks, the\n\nsecond one carries on and the system looks healthy — which is precisely what you\n\nasked it to do. The more graceful the degradation, the longer the outage hides.\n\nI still think the fallback was a good design. What I would change is that a\n\nfallback should be loud in the environment where it should never happen. In\n\ndevelopment, fall back silently. In the deployment where a backend is definitionally\n\npresent, falling back is not degradation — it is an incident, and it should say so.\n\nThe one that had nothing to do with fallbacks\n\nWhile fixing those, I found a fourth, and it is my favourite.\n\nFive tool signatures took a run_id: str parameter. The agent framework builds a\n\ntool's declaration from its signature, which means every parameter is a field the\n\nmodel fills in. So the model filled it in — with \"run-001\", a plausible,\n\nwell-formatted identifier belonging to no run that has ever existed.\n\nEvery approval created that way was untraceable. The card in the queue named a run\n\nthat does not exist. The audit trail could not be correlated. And the idempotency\n\nkey was namespaced under the hallucinated id, so two proposals from genuinely\n\ndifferent runs could collide and one would be silently suppressed as a duplicate.\n\nA tool signature should carry what the model needs to decide, and nothing else.\n\nBookkeeping the model cannot know is bookkeeping the model will invent. The run id\n\nnow comes from a context variable, and a test asserts that no tool signature\n\ncontains a runtime-only field:\n\nRUNTIME_ONLY = {\"run_id\", \"trace_id\", \"actor\", \"agent\", \"step_id\"}\n\ndef test_no_tool_asks_the_model_for_runtime_bookkeeping():\n\noffenders = {\n\nf.**name**: sorted(RUNTIME_ONLY & set(signature(f).parameters))\n\nfor f in ALL_TOOLS\n\nif RUNTIME_ONLY & set(signature(f).parameters)\n\n}\n\nassert not offenders\n\nAnd the one where I destroyed the evidence myself\n\nThe project's centrepiece is an eval gate with an anti-gaming judge: an agent\n\nproposes a rewrite of its own instruction, the proposal is scored against a fixed\n\nsuite, and a second model argues that the improvement is not real. The stored\n\nrecord of one agent gaming its own test and being caught is the most valuable\n\nartefact the system produces.\n\nI ran a second, honest improvement round — no gamed candidate — because a gate\n\nthat has only ever said no is hard to distinguish from a gate wired to say no.\n\nIt was rejected for an ordinary reason: the proposer truncated the instruction\n\nmid-sentence and the score fell.\n\nAnd it overwrote the first record.\n\nVersion records were keyed on the proposed version number. The bump function is\n\ndeterministic, so every rejected proposal from 1.4.2 is named 1.5.0-rc. The\n\nsecond set() replaced the first. Nothing reported a loss, because overwriting is\n\nwhat set() is for.\n\nThe docstring on that function reads: \"Rejections are kept deliberately… a gate\n\nwhose refusals are not retained cannot be audited.\" It was true of the intent and\n\nfalse of the storage. Records are now one document per attempt, and the registry\n\nholds both verdicts — one rejection for gaming, one for quality. The pair is\n\nbetter evidence than either alone.\n\nThe last one: a refusal that was really a crash\n\nThe newest feature in the system is the one I was most nervous about. A discharge\n\nletter says to rest the leg on a cushion so the ankle sits higher than the hip,\n\nknee slightly bent. That is correct, a clinician wrote it, and it is delivered on\n\nthe worst possible medium — a sheet of A4 handed over once at a hospital desk to\n\na relative too worried to take it in. A week later it is being done slightly\n\nwrong and nobody knows.\n\nVideo is the only format that shows a movement, so the fleet proposes filming\n\nit, a human approves, and Veo renders the action. The clinician's sentence is\n\nquoted above the clip; the numbers stay in the text, because generated lettering\n\nis unreliable and a wrong figure on a care video is worse than no video.\n\nThe card can also say the instruction was not filmed — some instructions have\n\nno picture in them. \"The district nurse visits on Tuesdays\" is a fact, and a\n\npleasant generated shot of a calendar attached to medical paperwork reads as\n\nevidence. Refusing is the correct behaviour and the card shows the reason.\n\nThe first live run rendered nothing, and the reason shown to the user was:\n\nExpecting ',' delimiter: line 6 column 16 (char 583)\n\nI had asked the model for JSON in prose and parsed the reply. It wrote a scene\n\ncontaining a quotation mark, and the response stopped being JSON. Fine — except\n\nfor where that string went. The record is keyed by the approval id so the render\n\nnever runs twice, which meant a transient fault had been written into a\n\npermanent \"not filmed\" card, with a parser error where a carer would look for the\n\nreason, and nothing left in the system that would ever try again.\n\nTwo fixes, and the second is the one I keep thinking about. The schema is now\n\ndeclared to the serving layer instead of described in a prompt — the same lesson\n\nevery agent in this codebase was already built on, which I had failed to apply to\n\nthe one call that was not an agent. And a fault and a refusal are now different\n\nthings in the type: a refusal is a decision and it is final; an error means try\n\nagain later, and it deliberately leaves nothing behind.\n\nThey look identical on screen. That is exactly why they must not be stored alike.\n\nRefusing to help is not the same as being safe\n\nThe shared rule every agent in this fleet reads used to say \"you do not give\n\nmedical advice\". It was tidy, it was easy to defend, and it meant the part of\n\nthe folder the family most struggled with was the part the system would not\n\ntouch — while it went on cheerfully filing their insurance paperwork.\n\nThe rule is now precise instead of broad: carrying a clinician's words is\n\nallowed, authoring is not. The safety did not come from the silence. It comes\n\nfrom the quotation, and from a scope owned by the clinical department, which the\n\npolicy engine sends to a named human at any confidence — including a confidence\n\nof 1.0. Every clip on screen carries who approved it.\n\nBroad rules feel safer to write. They are mostly a way of moving the risk\n\nsomewhere you cannot see it.\n\nWhat I would tell myself three weeks ago\n\nDelete the safety net once, on purpose, in the environment that matters. Not\n\npermanently — just long enough to find out what it has been carrying.\n\nA component that \"did what it was told\" is not exonerated. All three of the\n\nfirst bugs had a blameless component chain. The failure was in the seams, and the\n\nseams are where the tests were not.\n\nNumbers in your README expire. I quoted an eval score of 0.67 → 0.92 in mine.\n\nBy the time I checked, the deployed record said 0.75 → 0.83. A judge with two\n\ntabs open would have caught it. I now regenerate the numbers from the running\n\nsystem before I claim them.\n\nBuilt for Google's All Things Agentic Hackathon, August 2026. Stack: Google ADK,\n\nGemini 3.5 and 3.6 on Vertex AI, Gemma for redaction, Gemini TTS and Veo, Cloud\n\nRun, Firestore, Pub/Sub, Cloud Trace. All data synthetic. The system writes no\n\ncare advice of its own — it carries a clinician's, quoted, and every clinical\n\naction waits for a person.", "url": "https://wpnews.pro/news/a-good-fallback-hides-the-failure-it-was-built-for", "canonical_source": "https://dev.to/znlong2203/a-good-fallback-hides-the-failure-it-was-built-for-1cgm", "published_at": "2026-08-29 19:22:30+00:00", "updated_at": "2026-08-29 19:49:30.998592+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-products"], "entities": ["Google", "All Things Agentic Hackathon", "Devpost"], "alternates": {"html": "https://wpnews.pro/news/a-good-fallback-hides-the-failure-it-was-built-for", "markdown": "https://wpnews.pro/news/a-good-fallback-hides-the-failure-it-was-built-for.md", "text": "https://wpnews.pro/news/a-good-fallback-hides-the-failure-it-was-built-for.txt", "jsonld": "https://wpnews.pro/news/a-good-fallback-hides-the-failure-it-was-built-for.jsonld"}}