Every example below is running code in a production charter. It covers 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. These 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 into the charter it runs under. Each rung closes a gap
the one below it leaves open.
make = your task runner · PROJ-000 = your issue key
· app/Services/Billing/ = your highest-risk module
The cheapest guardrail has three parts. Prose states the intent, a deny list makes it non-negotiable, and a sanctioned path makes compliance easy. No scripts, no hooks: three artifacts pointing the same direction.
This project runs entirely inside Docker. An agent that types php artisan migrate
runs it on the host, which has no PHP extension set and no database. Then, being helpful,
the agent invents a workaround. The failure is not the wrong command; it is that the
wrong command was available.
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.
"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/*)"
]
}
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)
toolchain.md auto-loads: its paths: glob is **, so it is in context every turn.
php artisan migrate. The deny pattern matches; the call never runs.
make migrate, not an invented workaround.
The rule alone is advisory: an agent stuck long enough reasons its way past prose ("just this once, to check the version"). The deny list alone is a wall with no door. It blocks the agent, names no alternative, and leaves it guessing at paths you never meant it to try.
A guardrail has three parts, and skipping any one of them costs you. First, state the intent, so the agent can generalise to cases you did not enumerate. Then enforce it mechanically, so the constraint does not depend on the agent's mood. Finally, leave a sanctioned path, so compliance is cheaper than evasion. Guardrails that only block produce creative circumvention.
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. That hook must inspect only the new content, so the gate judges the change rather than the codebase.
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 lets an agent reason about the edge case the
rule's letter does not cover.
---
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.
---
# 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
scripts/, vendor/, database/migrations/) → exit 0. A sensor that blocks on cases it does not understand is a sensor you will end up turning off.
// charter:allow and whole-run CHARTER_BYPASS=1. One legitimate exception should not require disabling the whole gate, because that habit is how gates die.
prohibited-patterns.md. Sensor and guide stay coupled, so fixing one prompts fixing the other.
The rule fires only when the file's path happens to match a loaded glob and the agent
happens to weight it. A dd() added while debugging survives to review,
and your review skims past one debug line in a large diff.
This is the compilation step. A rule you can express as a regex over new content
should be both prose and a hook. Prose so the agent generalises, and a hook so
compliance does not depend on generalisation. The test for whether a rule can compile
is falsifiability: "no env() outside config/" compiles;
"prefer clean code" never will.
commits.md is one rule file with two iron laws: no AI attribution, and no
commit with failing checks. Four sensors enforce those laws at four points in the
session lifecycle. Each failure is cheapest to catch at a different moment. One of
those sensors exists only to detect that the others are missing.
session-start.sh prints branch, last commit, dirty count, then confirms
the git pre-commit hook exists and is executable.
A missing hook is not an inconvenience; it is the silent absence of
verification, so it warns loudly.
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.
Stop hook catches the agent declaring "done" with uncommitted code
changes. It then runs make pre-commit before the turn can close.
make pre-commit. That target calls
detect-scope.sh to size the run and make charter-gates to
run the diff sensors.
# 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
# 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
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
Collapse these four sensors to one commit-time gate and you get the expensive failure
mode. First, a malformed subject line discovered after a six-minute full-suite run.
Worse still, the agent declares "done" on a red tree, because nothing checked at the
moment it made the claim. The SessionStart warning is the easiest of the
four to skip building, because it blocks nothing. Yet it catches a fresh clone where
make install-hooks never ran. There, every other sensor on this list is
silently absent.
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 add one more sensor that tells you when the first sensor disappears. 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.
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.
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. The caution is not
"be careful". 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.
---
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.
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.
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.
check-diff-size.sh: warns past 15 files, blocks past 40. Pure counting;
no idea what the card asked for.
scope-guard.md: reads the Jira card and the diff together. It flags
drive-by edits that are individually reasonable and collectively out of scope.
# 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
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 both a file-count check and a test-presence check.
Sensors have a cost/precision curve, and high-risk code needs both ends of it. Scripts are free, deterministic and dumb, so run them always. Agents are expensive, probabilistic and can read intent, so run them where the blast radius justifies the cost. One implementation detail makes the pair work: each one names the other in its output. A human who sees the cheap signal then knows the expensive check exists.
Everything above composes into one delivery run. An orchestrator derives the next phase from external state and dispatches it to a subagent. It 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 the flywheel from becoming a ratchet.
/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.
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 dispatching a phase requires a briefing, the loop has drifted into
holding state in conversation and must stop.
/implement, four review agents run in parallel. That
parallelism is legal because they are read-only sensors returning structured findings
against a shared schema. Reconciliation is dedup-and-filter, not a forensic merge,
and only the main loop applies fixes.
make drive-metrics KEY=PROJ-482: merged LOC against token cost, priced
from a rate table. It reports without editorialising, and states its two limits.
The arithmetic is list price, not an invoice, and LOC flatters a card that wrote a lot.
/learn, invoked with no argument, runs the retrospective itself from the
run's recorded artifacts. It proposes one rule diff, then stops for human approval.
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.
A flywheel that only adds rules is a ratchet, and every file the agent touches pays the
cost. So the charter measures itself. make charter-index
builds a descriptor index. Then make charter-which replays glob matching for
any path. It 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:
| Kind | Id | Matching glob | Bytes |
|---|---|---|---|
| rule | services | app/Services/** | 12,944 |
| rule | security | app/**/*.php | 9,669 |
| rule | commits | ** | 9,283 |
| feature | billing-flow | app/Services/Billing/** | 6,416 |
| rule | single-writer | ** | 5,651 |
| rule | feature-flags | app/**/*.php | 5,407 |
| rule | prohibited-patterns | app/**/*.php | 4,668 |
| rule | design-principles | app/**/*.php | 4,201 |
| rule | no-facades | app/**/*.php | 3,743 |
| rule | billing-high-risk | app/Services/Billing/** | 3,431 |
| rule | verification-before-completion | ** | 3,207 |
| rule | git-workflow | ** | 2,351 |
| rule | env-config | app/**/*.php | 2,058 |
| rule | toolchain | ** | 1,428 |
| rule | role-checks | app/**/*.php | 1,373 |
| TOTAL (over budget (60,000) across 15 charter files) | 75,830 | ||
Read the shape rather than the totals. Three rules load on **, so
every file in the repo pays for them. Five more load on app/**/*.php, which
is most of the backend. Only two are specific to this module. They are also the two
smallest entries in the table. That distribution is the finding: rules that say nothing
about this file consume almost the whole budget.
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. It marks the ones scoring zero
everywhere with a headstone. Both scans run inside /prune, the deliberate
counterpart to /learn.
Run the flywheel for six months without a brake, and the charter reaches the state every long-lived one does. Rules contradict each other, and globs point at deleted directories. The per-file load grows so large that the agent reads 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.
The closed loop is the point, but the brake is the lesson. A charter is a codebase: it
accumulates and it rots. 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 turns the abstract cost of
"one more rule" into a number attached to a specific file. That number is the only
form of cost anyone acts on.