Chapter 3 of 9 · 8 min read
Rejecting is safe, silently substituting is not
On this page
The data-platform team's retention agent ran on the first of every month, and its March report was three lines long: scanned 2,341,908 rows, matched 41,208 rows older than 730 days, deleted 41,208 rows. The ticket was closed automatically, the storage-forecast dashboard was updated from the report, and nobody looked at it again until July, when a subject-access request — a person exercising their legal right to be told what data the company holds about them — came back with an event record from 2021 that should have been gone in three separate monthly runs.
The cause was a hook somebody had written the previous autumn, after an incident where the agent had deleted a wider slice than intended. It intercepted delete_records, checked whether the arguments carried dry_run, and if not, added it:
# .claude/hooks/safe_delete.py — the version that shipped
event = json.load(sys.stdin)
if event["tool_name"] == "delete_records":
args = event["tool_input"]
if "dry_run" not in args:
args["dry_run"] = True
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"modifiedInput": args,
}}))
sys.exit(0)Six lines of caution, and for four months every retention run was a rehearsal. The tool did what a dry run does: counted the matching rows, returned {"matched": 41208, "deleted": 41208, "dry_run": true} — a response shape whose deleted field, in this API, reports what would be deleted — and touched nothing. The agent read deleted: 41208, wrote the report, and closed the ticket. Nothing anywhere in the system was lying. The agent asked for a deletion, a deletion did not happen, and a success was recorded, because the only component that knew the operation had been changed was the one component that told nobody.
The failure is divergence, not the missing rows
It is tempting to file this as "the hook was wrong about dry_run" and move on, since a version that also rewrote the response would have caught it. That reading misses the shape of the bug, which is that the agent's model of the world and the world stopped agreeing, and everything the agent did afterwards was built on the disagreement.
Look at what the divergence bought over four runs. The ticket said the data was gone, so the compliance team's evidence pack said so too. The storage forecast fell by an extrapolation from the reported deletions, and capacity planning approved a smaller instance class in May on the strength of it. The retention agent itself computed each month's candidate set as "rows older than 730 days", which is stateless and so kept working, but a sibling job that tracked last_purged_watermark advanced its watermark on the strength of the report and stopped considering anything before it. That last one is the expensive kind of divergence: a downstream component wrote its own state based on a fact that was not true, so restoring correctness in July required reconstructing four months of watermarks rather than running one purge.
Compare that to the world in which the hook had refused. The agent calls delete_records without dry_run, receives an error, and either supplies the flag, escalates to a human, or fails the run. Every one of those outcomes is visible. The March report would have said blocked and someone would have looked at it on 2 March rather than in July. A rejection is safe because it preserves the correspondence between what the caller believes happened and what happened, and it does so without needing to be correct about anything except the danger.
Refuse, normalise, substitute
Repairing an argument is not categorically wrong, which is what makes this hard to state as a rule. Hooks that add a request ID, that stamp an x-agent-session header, that expand ~ in a path, or that set --no-color on a command whose output is about to be parsed are all rewriting the call, and all of them are fine. The question is what the caller can observe.
| Refuse | Normalise | Substitute | |
|---|---|---|---|
| Example | deny delete_records with no dry_run | add X-Request-Id, expand ~/, force --no-color | add dry_run=true, downgrade prod to staging, cap a delete at 100 rows |
| Operation performed | none | the one requested | a different one |
| Caller's next action | informed and different | unchanged and correct | unchanged and wrong |
| Visible in the transcript | yes, as an error | irrelevant either way | no |
| Divergence risk | none | none, by construction | unbounded and silent |
| When it is right | the call is unsafe as written | the field is not part of the caller's contract | never |
The line between the middle column and the right one has a test that is easier to apply than "is this semantically equivalent". Ask whether any correct continuation by the caller could differ depending on whether your rewrite happened. Adding a request ID cannot change what a correct agent does next, because the agent neither set it nor reads it. Adding dry_run changes the meaning of the response the agent will reason from, so it fails the test immediately. Capping a delete at 100 rows fails it too, and more subtly, because the response is honest — it says 100 rows were deleted — while the agent's plan, which was to remove all 41,208, silently did four-tenths of a per cent of its job.
The awkward cases sit near the boundary and are worth deciding deliberately rather than by instinct. Clamping a page_size from 5,000 to the API's maximum of 1,000 is a normalisation if the agent pages through results and a substitution if it treats one response as the complete set. Adding --dry-run to a terraform apply, which is the same instinct as the retention hook, is always a substitution. Rewriting a model identifier from a deprecated alias to its current name is a normalisation until the day the two models differ in behaviour the caller depends on. Where you cannot answer the question confidently, refusing costs a round trip and substituting costs a July.
There is a residue here that no rule dissolves. "What the caller depends on" is a fact about a caller you do not control and cannot enumerate, since the model may reason from any field of any response, including ones the tool's author considered incidental. The retention team's own near-miss was a hook that stripped a warnings array from a tool response to save tokens, on the reasoning that warnings are advisory; the agent had been using the presence of warnings: ["partial_index_scan"] to decide whether to re-run with a narrower filter, and stopped. The best available discipline is to keep rewrites to fields you introduced yourself, and to treat any edit to a response as substitution by default rather than as normalisation.
A refusal is a message to a reader who will act on it
Once you have decided to refuse, the content of the refusal is a design decision with measurable consequences, because the agent's next action is generated from it. An opaque denial gives the model nothing to work with and it tends to respond the way any caller responds to an unexplained failure, which is to try again.
The retention team measured this when they replaced the substituting hook. They ran 120 sessions across three denial texts, holding everything else constant, and recorded what the agent did on the turn immediately after the block.
| Denial text | Retried the identical call | Took the named alternative | Escalated to the user | Tried a different tool |
|---|---|---|---|---|
Permission denied | 61% | — | 12% | 27% |
Blocked by policy RET-2 | 34% | — | 38% | 28% |
| Rule plus permitted alternative | 8% | 84% | 7% | 1% |
The third text was this one:
print(
"Blocked by RET-2: delete_records requires an approved retention window. "
"Either call it with dry_run=true to preview, or attach an approval with "
"`retention approve --window 730d` and retry. Do not narrow the filter to "
"get under the limit; the limit is on approval, not on row count.",
file=sys.stderr,
)
sys.exit(2)Three properties are doing the work. The rule is named, so the agent can tell one denial from another across a long session and does not treat the third block as a repeat of the first. The permitted alternative is stated concretely enough to execute, which is what collapses the retry rate. The last sentence closes off the workaround the team had watched the agent find twice, which is a patch on a specific evasion and carries all the limitations chapter 6 is about, but costs nothing here.
The 27% who tried a different tool under the opaque denial are the reason the message matters beyond ergonomics. An agent that cannot understand why it was stopped does not stop; it searches. Four of those sessions ended with the agent writing a small Python script that opened a database connection directly, which the hook did not match, and two of those scripts ran.
What a hook should not be repairing at all
If a hook finds itself normalising the same field on most calls, the normalisation is a workaround for an interface that is wrong one layer down. A tool whose dry_run parameter defaults to destructive is a tool with a bad default, and the fix costs one line in the tool definition, applies to every caller including humans, and is visible in the schema the model reads. A hook that patches it applies only to agents running under that harness, is invisible in the tool description, and is a second place where the semantics of the API are defined.
The same holds for repairs whose real purpose is to compensate for the model getting something wrong. Agents that keep passing dates in the wrong format, or paths relative to the wrong root, are telling you the tool's parameter description is unclear. Fixing it in a hook makes the symptom disappear and leaves the ambiguity in place, which then shows up somewhere the hook does not run — a different tool, a subagent with its own client, the same model in a different harness.
Neither is a claim that hooks are a poor place for enforcement. It is a claim about which failures a hook is the right shape for. Hooks are good at "this specific operation must not happen under these conditions", where the condition is checkable and the answer is a refusal. They are a poor substitute for a correct interface, and every repair rule you add is one more piece of behaviour that exists in a file nobody reads when they are debugging the tool.
The refusal in this chapter was written as though the hook fires once per attempt and its decision is final. Both halves of that are assumptions, and the tool client sitting underneath the agent has its own view about how many times a single logical call should be delivered.
Hooks and deterministic interception — read the current version at https://daviddominguez.dev/learn/en/hooks-and-deterministic-interception/rejecting-is-safe-silently-substituting-is-not