---
title: "I Almost Broke My Team's Guards by Shipping Just One Hook"
canonical: https://dxdev.com/blog/2026-09-07_shared-guardrails-atomic-deployment/
datePublished: 2026-07-26
---
The alert wasn't an alert. It was a diff review, and the diff was `.gitignore`: seven lines removed, seven added, swapping four blanket-ignored `.claude/*` entries for two narrow un-ignores. Small enough to skim. Small enough to almost ship without checking what else lived in that directory.

The fix for this one was supposed to be a one-hook job. On July 21, a serial leading-wildcard scan (`LIKE '%...'`) crawled six cache tables against the shared live prod DB and wedged it for about five minutes, throwing errors on every customer `/teams` page. The fix was a PreToolUse hook, `.claude/hooks/block-prod-db-scan.sh`, dependency-free, no ops-tools/node/python required. It inspects any Bash/Write/Edit/MultiEdit payload for a prod-DB connection marker (`company_DB_*`/`company_CACHE_*`, `DSN=leagueapp...`, pyodbc, sqlcmd, Invoke-Sqlcmd) combined with a leading-wildcard `LIKE`, and blocks it. Straightforward. The problem wasn't the hook. It was where it had to live to actually apply to everyone.

## Why one file couldn't stay personal

The hook only fires if it's wired into `.claude/settings.json`. That file had been fully gitignored repo-wide since an earlier fix, which meant every developer's settings were local and private, drifting independently. Ship the new guardrail the "safe" way, just paste it into my own `settings.local.json`, and it would protect exactly one clone: mine. Everyone else's next `git pull` would come down clean, no hook, no protection, and no signal that anything was missing.

So the real fix wasn't the hook script. It was un-ignoring `settings.json` so the wiring itself could be shared and version-controlled. That's the `.gitignore` diff:

```
-.claude/settings.local.json
-.claude/settings.json
-.claude/mcp.json
-.claude/*.lock
...
 .claude/*
!.claude/skills/
+!.claude/hooks/
+!.claude/settings.json
```

That's when I actually looked at what `settings.json` already wired in, instead of assuming it was blank because it had never been tracked before.

## The wrong turn

My first move was to just add the new hook entry to `settings.json` and push it. Fast, minimal diff, done. I ran `git ls-tree -r --name-only origin/develop -- .claude` to confirm what was already tracked on develop before I committed, mostly as a formality.

It wasn't a formality. Two other hooks were already sitting in `.claude/hooks/` on develop: `check-branch.sh`, a guard that blocks Edit/Write directly on master/develop, and `check-learnings.sh`. Neither was referenced anywhere in a tracked `settings.json`, because there wasn't one. Each developer's `settings.local.json` was the only thing wiring those hooks in, and it had drifted per-clone since the earlier fix gitignored the shared file. I checked `.claude` across all ten clones:

```
clone-1    hooks/ skills/ settings.local.json  3.6K
clone-2    hooks/ skills/ settings.local.json  1.0K
clone-3    hooks/ skills/ settings.local.json  549B
clone-4    hooks/ skills/ settings.json  261B settings.local.json  3.6K
```

Sizes ranging from 549 bytes to 3.6K on files with the same nominal purpose. If I'd committed a `settings.json` that only wired the new prod-DB hook, that file would become the tracked source of truth the moment anyone pulled it, and `check-branch.sh` would quietly stop firing for them, on develop and master both, the exact branches it exists to protect. I'd have traded one incident risk for a second, worse one, and shipped it as a fix.

## Shipping it atomically

The real change had three hooks going in together, not one: branch guard, pre-commit installer, and the new DB scan guardrail, all wired through the newly-tracked `settings.json`. Before pushing, I rehearsed the migration in a spare clone to see what a real developer's next pull would actually do, and confirmed it throws a merge conflict on their local `settings.json`/`settings.local.json`, not a silent overwrite. That's a feature: it forces the 30-second cleanup instead of skipping it.

Then we wrote the message that actually goes out, instead of a changelog entry nobody reads:

> FYI, I made some updates to help our development rules stay in sync across developers. This will cause your next pull to throw an error. Run the following prompt to get your local setup back in sync.

Paste-ready prompt attached, filed as its own follow-up for the two remaining local-config cleanups. The guardrail now self-propagates: anyone who cloned since the earlier gitignore change gets the branch guard back, and anyone who clones fresh gets all three hooks by default, no manual step to forget.

The lesson wasn't about the wildcard scan or the hook logic. It was that `.claude/settings.json` isn't a config file, it's a dependency graph, and every hook anyone's ever wired into it is a transitive dependent of whatever you touch next. Checking `git diff` on the file you're changing tells you nothing about the files that already live next to it.
