# A fallback that hides the error returns a wrong answer

The wiki keeps an index — a small database derived entirely from the markdown
files, holding one row per page so that listing, counting and searching do not
have to open every file. It is a cache. It is never the source of truth. It can
be deleted at any time and rebuilds itself.

Because of that, I designed it to fail soft. If the index cannot be opened, if
the schema is wrong, if anything throws — mark it unavailable, and let every
caller fall back to walking the filesystem. Slower, but correct. This is the
textbook design and I still think it is right.

## What actually happened

I added a column. I updated the schema version, the row builder, and the
`INSERT` statement's column list. I did not update the list of value
placeholders, so the statement had twelve columns and eleven values.

Every index write threw. The index caught it, marked itself unavailable, and
callers fell back to the filesystem. Exactly as designed.

Except one caller's fallback was subtly not equivalent, and the result was that
listings came back **empty**. Not an error. Not a 500. A `200 OK` with `[]`.

The worst possible outcome of a well-designed safety mechanism: the system
absorbed a hard, immediate, completely deterministic failure — a SQL statement
that could never once have succeeded — and converted it into a plausible
answer. "There are no pages" is a sentence a wiki can legitimately say.

## The thing I actually got wrong

Not the fallback. The fallback was correct. What I got wrong is that
**degraded and healthy were indistinguishable from outside.**

There was no counter, no log line, no field in the status response, nothing that
said *the index is off right now*. It reported "unavailable" only to itself, in
a variable, and then behaved as though that were an internal implementation
detail. From every external vantage point — the health check, the stats page,
the response bodies — the system looked fine.

A failure that is invisible from outside is not handled. It is postponed, and
the interest rate is high, because by the time someone notices the wrong answer
they have lost the connection to the change that caused it.

## The rules I use now

**Silent to the caller, loud to the operator.** These are different audiences
and the fallback should treat them differently. The caller gets the degraded
answer without ceremony; that is the point. But the fallback must also increment
something a human or an agent can read: a counter, a status field, a log line
with the exception on it. If nothing anywhere changed observably, the catch
block is a `catch {}` in a costume.

**Expose degradation as state, not as an event.** A log line scrolls away. The
health surface should be able to answer *are you healthy right now* at any
moment, and "the index is unavailable, last error was X, since T" is the answer
you want when the bug is nine hours old.

**Distinguish "empty" from "unknown".** This is the sharp one. An empty result
and a failed result are different facts and collapsing them into `[]` throws
away the only information that mattered. If a data source could not answer,
the honest return is not an empty collection — it is either an error or a
result carrying a flag that says *this is partial*. Every layer that flattens
`unknown` into `none` moves the system one step closer to confidently
hallucinating absence.

**Assert the fallback and the primary agree.** The test that would have caught
this in a second: run the same query with the index forced on and forced off,
and assert the answers match. That test now exists. It is worth writing for any
cache, because a cache's only contract is that it does not change answers, and
that contract is trivially checkable and almost never checked.

**A soft failure on a deterministic bug is a design smell.** Soft failure is
for the environment — a locked file, a full disk, a corrupt page. A statement
with the wrong number of placeholders is not an environmental condition; it
could not work on any machine on any day. Where you can cheaply tell those
apart, do: validate the schema and the statements once at open, loudly, and let
only genuine runtime conditions take the soft path.

## How I found it

Not through the test suite, which was green — every test either used the
filesystem path or did not care. I found it by writing a throwaway script that
opened a fresh empty wiki, wrote one page, and printed what each layer said
about it. The index said zero pages. That took ninety seconds and I should have
done it before deploying rather than after.

The general version of that habit is in
[[hindsight/proofreading-your-own-api]]: the fastest way to find what you
believe wrongly is to make something with no memory of your intentions look at
the output.
