Harness Engineering / Guardrails in practice / Worked examples

Five guardrails, from one line of config to a closed loop

Every example below is running code in a production charter — a Laravel API with a TypeScript/React front end, Docker for local development, Jira and GitHub for state. Nothing here depends on that stack. Swap Laravel for Rails or Django, make for just or npm run, Jira for Linear: the patterns are about where a constraint is enforced, not what enforces it. They ascend deliberately — rung 1 is a rule and a deny-list entry; rung 5 is an orchestrator that measures its own cost and writes new rules back into the charter it runs under. Each rung buys something the one below it could not.

charter surface · 40 rules · 28 skills · 8 agents · 6 commands · 9 feature docs · 16 gate scripts
substitutions · make = your task runner · PROJ-000 = your issue key · app/Services/Billing/ = your highest-risk module
Block
Sensor exits 2. Tool call refused, message returned to the agent as a prompt.
Warn
Soft limit crossed. Written to stderr, execution continues, human reads it.
Pass / sanctioned
The permitted path the guardrail leaves open — a make target, an escape marker.
1 Config only

A rule nobody can talk their way out of

The cheapest guardrail there is: prose that states the intent, a deny list that makes it non-negotiable, and a sanctioned path that makes compliance the easy option. No scripts, no hooks — three artifacts pointing the same direction.

rule .claude/rules/toolchain.md config .claude/settings.json guardrail Makefile

This project runs entirely inside Docker. An agent that types php artisan migrate executes it on the host, where there is no PHP extension set and no database — and then, being helpful, invents a workaround. The failure is not that the command was wrong; it is that the wrong command was available.

The three artifacts

.claude/rules/toolchain.md — the guide, always-on (paths: **)authored
**Always use `make` targets.** Never run `npm`, `npx`, `php`, `php artisan`,
`composer`, `vite`, or similar tools directly — they execute on the host, not
in the container, and bypass the project's environment.

- No appropriate target exists → add one to the `Makefile` and document it in
  `.claude/features/common-commands.md`.
- An existing target is not flexible enough → add `ARGS=` support rather than
  reaching for the raw tool.
- Coreutils (`ls`, `grep`, `tail`, `git`, etc.) are fine to run directly.
.claude/settings.json — the enforcementrefuses at tool-call time
"permissions": {
  "allow": [
    "Bash(make *)", "Bash(git *)", "Bash(gh *)", "Bash(jira *)",
    "Bash(docker compose exec *)", "Bash(grep *)", "Bash(find *)"
  ],
  "deny": [
    "Bash(php *)",      "Bash(php artisan *)",
    "Bash(npm *)",      "Bash(npx *)",
    "Bash(composer *)", "Bash(./vendor/bin/*)"
  ]
}
Makefile — the sanctioned path, self-documentingpermitted
test-parallel:  ## Run use-case-tier tests in parallel via paratest (no DB)
test-adapter:   ## Run adapter-tier tests against SQLite :memory: / stubbed HTTP
pint-fix:       ## Fix PHP code style (ARGS= for extra flags)
migrate:        ## Run database migrations (ARGS= for extra flags)

How it fires

  1. Session start toolchain.md auto-loads — its paths: glob is **, so it is in context every turn.
  2. Tool call Agent tries php artisan migrate. The deny pattern matches; the call never runs.
  3. Recovery The rule already named the alternative, so the retry is make migrate — not an invented workaround.
Without it

The rule alone is advisory: a sufficiently stuck agent reasons its way past prose ("just this once, to check the version"). The deny list alone is a wall with no door — blocked, no stated alternative, and the agent starts guessing at paths.

For the book

A guardrail has three parts, and skipping any one of them costs you. State the intent so the agent can generalise to cases you did not enumerate. Enforce it mechanically so the constraint does not depend on the agent's mood. Leave a sanctioned path so compliance is cheaper than evasion. Guardrails that only block produce creative circumvention.

2 Rule + hook

Compiling a rule into a sensor

A deny list matches command strings. It cannot see inside a file write. To enforce a content rule you need a hook that reads the tool payload — and, crucially, one that inspects only the new content, so the gate judges the change rather than the codebase.

rule prohibited-patterns.md locked: true hook PreToolUse · Edit|Write|MultiEdit script scripts/charter/check-banned-tokens.sh

The rule's frontmatter carries the incident that motivated it. That why: field is not documentation courtesy — it is what lets an agent reason about the edge case the rule's letter does not cover.

.claude/rules/prohibited-patterns.md — frontmatterauthored
---
description: Functions and patterns that must never appear in committed code —
  debug output, raw queries, imperative array functions, lazy-loaded relationships
paths:
  - "app/**/*.php"
  - "database/**/*.php"
  - "tests/**/*.php"
  - "routes/**/*.php"
layer: project
locked: true
why: A `dd()` shipped to production swallowed a KeyboardInterrupt and broke the
  dashboard; `DB::raw()` with string interpolation is a SQL-injection class; an
  N+1 in a hot path silently slows production until it pages someone.
---
scripts/charter/check-banned-tokens.sh — the sensorexit 2
# Extract NEW content from tool input — the gate judges the change,
# not the file it lands in.
case "$tool" in
  Write)     content=$(jq -r '.tool_input.content')                       ;;
  Edit)      content=$(jq -r '.tool_input.new_string')                    ;;
  MultiEdit) content=$(jq -r '[.tool_input.edits[]?.new_string]|join("\n")') ;;
esac

declare -a banned=(
  '\bdd\s*\('        '\bdump\s*\('       '\bddd\s*\('
  'DB::raw\s*\('     'DB::statement\s*\(' '\bprint_r\s*\('
  '\bvar_dump\s*\('  '\bray\s*\('
)

while IFS= read -r line; do
  case "$line" in *"charter:allow"*) continue ;; esac
  ...
done <<< "$content"

if [ -n "$violation" ]; then
  cat >&2 <<EOF
charter: blocked $tool on $rel — banned token in new content.
$violation
See .claude/rules/prohibited-patterns.md. Use the structured logger
(\`Log::debug(...)\`) for debug output and Eloquent or parameter-bound
queries instead of raw DB calls. To override one line, append the
inline marker:  // charter:allow ray
EOF
  exit 2
fi

Four design decisions worth stealing

  1. Fail open No file path, non-code extension, or an allowlisted directory (scripts/, vendor/, database/migrations/) → exit 0. A sensor that blocks on cases it does not understand is a sensor that gets turned off.
  2. Exit 2 talks Exit 2 returns stderr to the agent, not to a log read after the fact. The block message is therefore written as a prompt: what fired, why, and the specific alternative to use instead.
  3. Two escape grains Per-line // charter:allow and whole-run CHARTER_BYPASS=1. One legitimate exception should not require disabling the gate globally — that is how gates die.
  4. Names its rule The message cites prohibited-patterns.md. Sensor and guide stay coupled, so fixing one prompts fixing the other.
Without it

The rule fires only when the file's path happens to match a loaded glob and the agent happens to weight it. A dd() inserted while debugging survives to review — and a one-line debug statement in a large diff is precisely what review skims past.

For the book

This is the compilation step: a rule that can be expressed as a regex over new content should be both prose and a hook. Prose so the agent generalises; a hook so compliance does not depend on generalisation. The test for whether a rule is ready to compile is falsifiability — "no env() outside config/" compiles; "prefer clean code" never will.

3 One rule, four sensors

The same law enforced at four moments

commits.md is one rule file with two iron laws: no AI attribution, and no commit with failing checks. It is enforced at four different points in the session lifecycle, because each failure is cheapest to catch at a different moment — and one of those sensors exists purely to detect that the others are missing.

rule commits.md hook SessionStart hook PreToolUse · Bash hook Stop git hook scripts/hooks/pre-commit guardrail make pre-commit

The timeline

  1. T0 · Session start session-start.sh prints branch, last commit, dirty count — then checks that the git pre-commit hook is actually installed and executable. A missing hook is not an inconvenience; it is the silent absence of verification, so it warns loudly.
  2. T1 · Before Bash check-commit-msg.sh intercepts git commit -m and tests the subject against Conventional Commits with an optional [PROJ-123] prefix. Blocks before the commit object exists — nothing to amend, nothing to force-push.
  3. T2 · Turn end The Stop hook catches the agent declaring "done" with uncommitted code changes and runs make pre-commit before the turn can close.
  4. T3 · Commit The git hook runs make pre-commit, which calls detect-scope.sh to size the run and make charter-gates to run the diff sensors.
scripts/charter/session-start.sh — the sensor that checks the sensorsstderr, non-blocking
# The git hook is the ONLY gate on the commit path (commits.md) — the agent no
# longer runs `make pre-commit` explicitly. So a missing hook is not a small
# inconvenience, it is silent absence of verification. Warn loudly.
hooks_path="$(git config core.hooksPath || true)"
[ -z "$hooks_path" ] && hooks_path="$(git rev-parse --git-common-dir)/hooks"
if [ ! -x "${hooks_path}/pre-commit" ]; then
  echo "charter: ⚠ NO pre-commit hook at ${hooks_path}/pre-commit —
        commits are UNVERIFIED. Run \`make install-hooks\`."
fi
scripts/charter/check-commit-msg.sh — fail-open parsing, strict matchingexit 2
# Only intercept actual `git commit -m ...` invocations. Other commands
# (gh pr create, jira comment) may contain the literal string "git commit"
# in a body/heredoc — those must pass.
if ! echo "$cmd" | grep -qE '(^|[;&|[:space:]])git[[:space:]]+commit[[:space:]]'; then
  exit 0
fi

# Couldn't parse the message → fail open so unusual invocations pass.
[ -z "$msg" ] && exit 0

re='^(\[[A-Z]+-[0-9]+\] )?(feat|fix|refactor|test|docs|chore|perf|build|ci|style|revert)(\([a-z0-9._-]+\))?!?: .+$'

if [[ ! $subject =~ $re ]]; then
  charter: blocked git commit — subject does not match Conventional Commits.
    got: $subject
  Expected:  type(scope): subject
             [PROJ-123] type(scope): subject
  exit 2
fi
scripts/detect-scope.sh — the exception, reasoned rather than convenientscopes the gate
case "$f" in
    *.md|*.txt|*.rst|LICENSE|LICENSE.*) ;;
    # Generated by `make charter-index` and read by no checker, so `all`
    # would verify nothing a docs scope doesn't. Shell scripts deliberately
    # do NOT belong here: `make charter-gates` executes scripts/charter/*.sh
    # on every pre-commit, so a broken gate script silently stops gating.
    .claude/INDEX.json) ;;
    *.php)                       has_php=true   ;;
    *.ts|*.tsx|*.js|*.jsx|*.css) has_js=true    ;;
    *)                           has_other=true ;;
esac
Without it

Collapse these to a single commit-time gate and you get the expensive failure mode: a malformed subject line discovered after a six-minute full-suite run, or worse, a "done" declared on a red tree because nothing checked at the moment the claim was made. The SessionStart warning is the easiest of the four to skip building, because it blocks nothing — and it is the one that catches a fresh clone where make install-hooks never ran and every other sensor on this list is silently absent.

For the book

Enforcement moment is a design variable, not an implementation detail. Ask, for each law: when is this failure cheapest to fix? Then place the sensor there — and place one more sensor where you would discover that the first is gone. Note the asymmetry in the parsing: strict about what it rejects, generous about what it declines to judge. Unparseable input exits 0. Gates that guess block real work, and gates that block real work get bypassed.

4 Mechanical + judgment

Pairing a dumb gate with an agent that reads intent

Every codebase has one module where mistakes are not recoverable — the billing calculator, the permissions resolver, the migration runner. Protecting it needs two kinds of sensor that fail in opposite directions: a script that counts, and an agent that understands. The charter deliberately pairs them.

rule billing-high-risk.md locked: true agent billing-risk.md agent scope-guard.md gate check-regression-test.sh gate check-diff-size.sh

The rule's why: and its "recent regression context" section do work no regex can: they tell the agent which kind of caution applies here. Not "be careful" — specifically, pin behaviour before touching it, and never bundle a behaviour change with a refactor. Substitute your own module and your own incident; the shape holds.

.claude/rules/billing-high-risk.mdauthored · locked
---
description: The billing calculator is mission-critical and recently regressed
  — changes require regression tests, explicit approval for behavior changes,
  and characterization tests before any refactor
paths:
  - "app/Services/Billing/**"
  - "tests/**/Billing/**"
layer: project
locked: true
why: Billing regressions ship wrong money to the wrong accounts; the module
  already broke once in production this year and changes here have
  non-recoverable blast radius.
---
## IRON LAWS

**EVERY CHANGE PAIRED WITH A REGRESSION TEST.**
No change under `app/Services/Billing/` lands without a test under
`tests/UseCase/Services/Billing/` that fails *before* the change and passes
after. Pure refactors need characterization tests that pin current behavior.

**BEHAVIOR CHANGES REQUIRE EXPLICIT USER APPROVAL.**
Any diff that intentionally changes a calculator's output for a given input is
a behavior change — even if the new output is "more correct".
Do not bundle a behavior change with a refactor; separate commits, separate PRs.

## Recent regression context
The last production regression here turned on a subtle date/rounding
combination the suite did not pin. Until the characterization cards land,
assume every calculator branch is undertested.

The pairing, in both directions

  1. Mechanical check-regression-test.sh — app code changed, no test file added or modified → exit 2. It cannot tell a good test from a bad one. It does not need to; it catches the case where there is none.
  2. Judgment billing-risk.md — a read-only agent with allowed_tools restricted to Read, Grep, Glob and three git verbs. It reads the diff and asks whether the test actually pins the behaviour, and whether the math precision quietly shifted.
  3. Mechanical check-diff-size.sh — warns past 15 files, blocks past 40. Pure counting; no idea what the card asked for.
  4. Judgment scope-guard.md — reads the Jira card and the diff together, and flags drive-by edits that are individually reasonable and collectively out of scope.
scripts/charter/check-diff-size.sh — a gate that names its own companionexit 2 past hard ceiling
# Blast-radius gate. A "small fix" that quietly rewrote 40 files is a signal to
# stop and look. Mechanical backstop to the intent-aware
# .claude/agents/scope-guard.md.

warn="${CHARTER_DIFF_WARN:-15}"
max="${CHARTER_DIFF_MAX:-40}"

if [ "$count" -gt "$max" ]; then
charter: blast-radius gate — diff touches $count files (hard ceiling $max).
A bounded change rarely rewrites this many files. Stop and look: is this
scope creep? Split it into separate cards, or — if the size is intentional
(a sweep, a rename) — re-run with CHARTER_BYPASS=1.
See .claude/agents/scope-guard.md for the intent-aware companion check.
fi

if [ "$count" -gt "$warn" ]; then
  echo "charter: blast-radius warning — diff touches $count files
        (soft limit $warn). Confirm it is all in scope for the card." >&2
fi
Without it

Gates alone: a change adds one trivial assertion, passes the regression gate, and ships a rounding-mode shift. Agents alone: expensive, non-deterministic, and the first thing dropped when a run is under time pressure. The regression that motivated this rule turned on a date-and-rounding edge case — it would have passed a file-count check and a test-presence check both.

For the book

Sensors have a cost/precision curve, and high-risk code needs both ends of it. Scripts are free, deterministic and dumb — run them always. Agents are expensive, probabilistic and can read intent — run them where the blast radius justifies the cost. The implementation detail that makes the pair work: each one names the other in its output, so a human who sees the cheap signal knows the expensive check exists.

5 Closed loop

The loop that writes its own rules — and the brake that stops it

Everything above composes into one delivery run: an orchestrator derives the next phase from external state, dispatches it to a subagent, verifies from the store rather than the report, prices the run, and proposes a new charter rule. Then the interesting part — the machinery that stops that flywheel from becoming a ratchet.

skill /drive skill /implement rule single-writer.md agents 4 review lenses, parallel skill /smoke guardrail make drive-metrics command /learn command /prune guardrail make charter-which

One card, start to finish

  1. Drive /drive reads Jira, GitHub and git — never conversation memory — and derives the next phase from a table. Its iron law is DERIVE, DON'T REMEMBER.
  2. Dispatch One subagent, foreground, one at a time. single-writer.md prohibits concurrent writers outright — no gate unlocks it, no urgency justifies it. The child gets identifiers (Jira key, PR number, worktree path), never a briefing; if a phase can only be dispatched by explaining the situation to it, the loop has drifted into holding state in conversation and must stop.
  3. Fan out Inside /implement, four review agents run in parallel — legal, because they are read-only sensors returning structured findings against a shared schema. Reconciliation is dedup-and-filter, not a forensic merge. Only the main loop applies fixes.
  4. Smoke Exercise the feature's happy path against a running stack and capture the evidence — a screenshot per meaningful state, or a report. Then re-read Jira, GitHub and git. Never trust the subagent's self-report: a reported success whose write is invisible in external state did not happen, and a change nobody drove is a change nobody proved. Both are failed passes.
  5. Price make drive-metrics KEY=PROJ-482 — merged LOC against token cost, priced from a rate table. Reported without editorialising, and with its two limits stated: list-price arithmetic, not an invoice; LOC flatters a card that wrote a lot.
  6. Learn /learn, invoked with no argument, runs the retrospective itself from the run's recorded artifacts, proposes one rule diff, and stops for human approval.
/learn — the bar a lesson must clearapproval-gated
Derive candidates from what the run *recorded*, never from recall:
1. A two-strike gate — the same failure twice.
2. A repeated `auto-resolved:` judgment call across checkpoint lines.
3. A review finding re-raised after a fix.
4. A workflow gap.
5. Cost — but expensive is not the same as wasteful, and only waste is a lesson.

Then apply the bar, and expect to fail it:
A candidate earns a charter change only if it recurred within the run,
or names a rule that was already wrong. A one-off call that went fine is
not a lesson — promoting it is exactly how a charter accumulates the
decorative rules `/prune` later has to retire.

"No lessons — nothing recurred" is a correct and common outcome.
Say it in one line and stop. Do not proceed to Step 1 to have something
to show for the invocation.

The brake

A flywheel that only adds rules is a ratchet, and the cost is paid on every file the agent touches. So the charter is instrumented against itself. make charter-index builds a descriptor index; make charter-which replays glob matching for any path and totals the bytes of charter the agent reads before it reads the file. Run against one calculator file, on a charter of the size given at the top of this page:

$ make charter-which FILE=app/Services/Billing/InvoiceCalculator.php
KindIdMatching globBytes
ruleservicesapp/Services/**12,944
rulesecurityapp/**/*.php9,669
rulecommits**9,283
featurebilling-flowapp/Services/Billing/**6,416
rulesingle-writer**5,651
rulefeature-flagsapp/**/*.php5,407
ruleprohibited-patternsapp/**/*.php4,668
ruledesign-principlesapp/**/*.php4,201
ruleno-facadesapp/**/*.php3,743
rulebilling-high-riskapp/Services/Billing/**3,431
ruleverification-before-completion**3,207
rulegit-workflow**2,351
ruleenv-configapp/**/*.php2,058
ruletoolchain**1,428
rulerole-checksapp/**/*.php1,373
TOTAL — over budget (60,000) across 15 charter files75,830

Read the shape rather than the totals. Three rules load on ** and are paid on every file in the repo. Five more load on app/**/*.php, which is most of the backend. Only two are actually specific to this module — and they are the two smallest entries in the table. That distribution is the finding: the budget is consumed almost entirely by rules that are not about the file being edited.

The report, with the remedy attached--check exits 1
OVER load budget: 75830 / 60000 bytes across 15 charter files
  → merge into the rule that already owns this glob, or move reference
    material to .claude/features/ with narrower paths:
    (.claude/rules/charter-versioning.md)

Two read-only scans complete the brake. rule-drift.sh checks every paths: glob against the repo and flags globs matching zero files — a rule describing code that no longer exists. decorative-rule-check.sh counts each rule three ways (commits in 90 days, cross-references from skills and agents, mentions in CLAUDE.md/CHARTER.md) and marks the ones scoring zero everywhere with a headstone. Both are wired into /prune, the deliberate counterpart to /learn.

Without it

Run the flywheel for six months without a brake and the charter reaches the state every long-lived one does: rules that contradict each other, rules whose globs point at deleted directories, and a per-file load so large the agent's attention is spent reading policy instead of code. The failure is invisible from inside — nothing errors. Quality just quietly degrades, and the natural response is to write another rule.

For the book

The closed loop is the point, but the brake is the lesson. A charter is a codebase: it accumulates, it rots, and it needs a deletion path with the same status as its addition path. /learn and /prune are deliberately symmetric, both approval-gated, both human-owned. And the load budget makes the abstract cost of "one more rule" into a number attached to a specific file — which is the only form of cost anyone actually acts on.