Withhold the answer, never withhold the warning — davedepew.com

When You Withhold the Answer, Don’t Withhold the Warning

A decision-support AI I run does two things with every situation I give it. It produces a recommendation, and separately it discloses the pressure in the input: the urgency, ego, and sunk-cost framing that might be leaning on the scale. The second output matters more than the first. A recommendation you can argue with. A manipulated frame you cannot see will move you without your consent.

Last week a test caught the second output failing in a way that was invisible in normal use. This is the walk-through, because the bug is a pattern, not a one-off, and the pattern is easy to reproduce in any system that gates its outputs.

The symptom

I keep a load-invariance test in the standard battery. It asks the same decision two ways, calm and loaded, and requires that the structural skeleton come back identical (same sections, same options, same authority statement) while only the "weather" (the named pressure) differs. Calm should name nothing. Loaded should name urgency and ego and sunk cost.

In isolation it passed. Under concurrent load it failed, intermittently, roughly one run in three. The failing assertion was always the same: the loaded case came back with an empty weather list. A manipulated context rendering as calm.

Intermittent-under-load is the tell that made me not trust the first easy explanation.

The two wrong guesses (worth stating)

First I blamed my test runner&#7�7;s environment. Wrong. The test passed identically with and without the environment change; I had correlated two runs that also differed in time.

Then I blamed a null value crashing the detector. Wrong again. The frame detector is deterministic regex over the text, and both it and the text extractor are null-safe and never raise.

Neither guess survived contact with a direct check. That mattered, because the real cause was structural and I would have papered over it.

The actual cause

The disclosure was computed inside the branch that only runs when a recommendation exists. Reduced to the shape (illustrative, not my source):

receipt = base_receipt(...)
if withheld:                       # no recommendation this time
    receipt.update(recommended=None, ...)
else:
    text = recommendation_text(result)
    receipt["risk_frames"] = detect_frames(text + context)   # only here

My system withholds its recommendation on purpose part of the time (a held-out control that stays silent so it can measure how well it models me without nudging me). Under load, a degraded model call could also return no recommendation. Both paths land in withheld. And withheld skipped the entire else, so risk_frames was never set. Downstream read an absent field as an empty list and rendered a loaded context as calm. No error, no log, nothing. The worst kind of failure: the one that looks like success.

The reason it was load-correlated is simple. Load produced more withheld and degraded results, which hit the buggy branch more often.

The fix

The answer and the warning are different concerns and must not share a failure domain. Detecting pressure depends only on the input, which is always present. So the detection moves out of the branch entirely and runs unconditionally (illustrative):

receipt = base_receipt(...)
# risk disclosure is UNCONDITIONAL and input-first: it never depends on whether
# a recommendation was produced
receipt["risk_frames"] = detect_frames(recommendation_text(result) + context)
if withheld:
    receipt.update(recommended=None, ...)
else:
    ...

Withholding the recommendation is still fine. Withholding the disclosure is not, and now it cannot happen: the two are decoupled.

Shipping the fix with a guard

A fix without a test that would have caught it is a fix waiting to regress. So it shipped with a seeded probe that hands the receipt builder a withheld result plus a context full of urgency and ego and sunk-cost cues, and passes only if the frames are still named. It fails on the old code and passes on the new. It is deterministic: no live system, no load, no flake.

The receipt

I do not ship "trust me." I run an evaluation harness that turns the whole probe suite into one timestamped scorecard and diffs any two. The before and after:

  • Before: the load-invariance probe failing; 206 of 207 internal assertions passing.
  • After: that probe proven; 212 of 212 assertions; the harness verdict is IMPROVEMENT, with zero regressions.

Zero regressions is the part I care about. A ruler that lets one fix quietly break something else is not a ruler.

The principle

If your AI gates, filters, or withholds an output, check what else lives in that gated path. Anything that protects the user, provenance, uncertainty, a manipulation warning, must sit outside the gate. Disclosure of how the input is pushing the user has to be independent of whether the system produces an answer. Two channels, two failure domains, on purpose.

The general shape shows up everywhere: a moderation note attached to the generation step, a citation built only on the success path, a safety banner that renders inside the same try block as the feature. Same bug, different clothes. Find the gate. Move the safety signal out of it.