A cancel button that lies, and retrieval that returns garbage confidently

Two problems six weeks apart that turned out to be the same problem. A cancel button that stopped the UI but not the work. A search that confidently returned a table-of-contents fragment. Both fixed by refusing to make the primary mechanism smarter.

Two problems, six weeks apart, that turned out to be the same problem.

Problem one: a cancel button that lies

A debugging run had been spinning for 219 seconds. There was a Cancel button under the spinner. I clicked it. The UI said "cancelled."

The backend kept running.

The first implementation was the easy half: an abort controller on the frontend, a cancel button wired to it, the fetch promise rejecting, the UI swapping to a cancelled state. From the user's seat that looks complete. It isn't. The server-side task was still awaiting the model call. Tokens kept burning. The audit log still recorded a successful completion for a request no client was listening to. Restart the server and you'd see the work finish in the logs, minutes later, for a run the user believed they'd stopped.

A cancel button that doesn't propagate to the real work is worse than no cancel button. It teaches the user to trust a control that's lying to them.

The real fix had three parts, all shipping together: the in-flight work has to be tracked as a handle the cancel path can reach; there has to be an endpoint that actually cancels it; and when cancellation propagates, the audit log has to record that — not a phantom success.

The frontend then does two things in order: tell the backend to cancel first, then abort the local request. Backend-first is what frees the model call and writes the truthful event. The local abort is best-effort UI cleanup. If the cancel call fails — network down, backend gone — the abort still fires so the user isn't stranded staring at a spinner, but the backend task continues. That's a known degraded mode. Known, and stated, rather than silent.

The caveat I'm being honest about

Cancellation fires immediately in the async task. But when the model runs as a subprocess, that subprocess may keep going for several seconds while the OS cleans it up. The HTTP contract honors the cancel instantly and the audit event fires on time — but full transport-layer teardown needs a different subprocess API with a handle the cancel path can terminate. That's queued as its own piece of work, hindi ko binabalewala (I'm not waving it away).

Problem two: retrieval that returns garbage confidently

Six weeks later, a different system. The knowledge search UI, asked "what is SysML", returned three results. The top hit was a table-of-contents fragment — a line of dots and a page number. The second was German-language security prose. The third was a chapter about dependency injection in C#.

All three scored around 0.20. Near-random.

The tempting fixes were all single-layer: tune the embedding model, swap the chunker, add a reranker. But the failure was compounded — three separate things wrong at once, and fixing any one would have produced a slightly-less-bad version of the same garbage.

The deepest of the three: the chunker cuts documents into fixed-size character windows. A page containing 9.4.16.1 Vector Functions Overview.........395 gets indexed as a legitimate chunk. Its embedding lands near "what is sysml" because the word vector and a numeric neighborhood happen to overlap. Nothing about the retriever is broken. The input to it is garbage.

The pattern, stated once

Retrieval scoring alone will never guarantee chunk quality. The response isn't "tune the embedding model harder." It's "gate the chunks that carry no information at all, before they can be retrieved or shown to a model."

Two gates, same rules. One at ingest, so bad chunks never enter the index. One at answer time, so a legacy bad chunk sitting in an older index can't reach the model anyway. Defense in depth — because retrieval quality is a moving target, and any single gate eventually gets circumvented by a corpus change or a chunker tweak.

Four fixes, one shape

Once I saw it, the pattern was everywhere in this project:

Every single one was multi-layer defense chosen over a single-layer "make the primary mechanism smarter" instinct.

The primary mechanism is never the place to put the guarantee. The cache, the reviewer, the abort controller, the retriever — each is a mechanism doing its job correctly. The guarantee has to live somewhere the mechanism can't fail you.

Why I keep relearning this

Because the single-layer fix is always the one that feels like engineering. Tuning the embedding model is interesting work. Writing a quality gate that throws away chunks made of dots and page numbers is boring plumbing.

The boring plumbing is the thing that holds. Matagal pero matibay (slow but solid).

The cancellation contract and the retrieval rebuild

The implementation detail, the code, and the specific tooling. Same password as the other build-log posts — or request access below.

Build log entry from an append-only debugging journal. The blog is the narrative; the session docs are the audit trail.