Spec-Driven Dev
SDD for Dummies·Module 2 · The Enforced Gate·L4·55 minGate

The Enforced Gate

Build gate.sh, write a hook that allows, asks, or blocks, make every block explain itself, and log each decision where no one engineer can edit it.

Before this: L3 From spec to failing test

What you'll learn
  • List the checks an agent must pass before a person looks: build, tests, lint, eval suite.
  • Write a hook that can allow, ask, or block an agent action.
  • Make a block explain itself and name the route to approval.
  • Record every gate decision with a timestamp and a verdict, outside any one engineer's control.
  • Wire a human gate that pauses a production action.

Why this matters

Nadia asks Omar to tidy up. Omar types into Quill: "clean up stale gift cards." Quill reads the schema, finds a gift_cards table with an expires_at column, and runs a delete against the production database from the terminal. Four hundred and twelve cards are gone. Some of them belonged to customers who had not spent them yet. Priya finds out from a complaint. Nothing stopped Quill, because nothing was there to stop it. The spec for Shelf says production writes need a human, but a sentence in a file does not reach out and grab a shell command. With a gate in place, the command would have paused, explained why, and named Priya as the approver. This lesson builds that gate.

1. A gate is a check plus a decision

A gate is a point in the workflow where work is held until something is true. An enforced gate is one that a machine applies every time, whether or not anyone remembers. It has two halves. The first half is a set of automated checks: does it build, do the tests pass, is the lint clean, does the eval suite still score above the threshold. The second half is a human decision that the checks pause for: may this refund run, may this file change, may this delete happen.

The order matters. Checks run first, so a person never spends attention on work that cannot even build. The human decision comes last, on work that has already passed everything a machine can verify.

2. gate.sh: the checks before a person looks

The gate script is one file, gate.sh, at the root of the Shelf repository. Run with no arguments, it runs the checks in order and stops at the first failure with a non-zero exit code. A non-zero exit is the universal signal for "no": CI reads it, Quill reads it, and a human reading the log sees it.

The four checks run in a deliberate order: build, tests, lint, eval suite. Build first because nothing else means anything if the code does not compile. Tests next because they are the acceptance criteria from spec.md made executable. Lint after tests because style problems on broken code are not worth reporting. The eval suite last because it is the slowest and the most expensive.

#!/usr/bin/env bash
# gate.sh: run every check before a person looks. Exit non-zero on the first failure.
set -euo pipefail

step() {
  local name="$1"; shift
  echo "==> $name"
  if ! "$@"; then
    echo "GATE FAILED at $name (exit $?)" >&2
    exit 1
  fi
}

step build   npm run build
step tests   make test
step lint    npm run lint
step evals   node evals/run.mjs --threshold 0.9

echo "GATE PASSED $(date -u +%FT%TZ)"

Each step prints its name, so the log shows how far the run got. The final line stamps the pass with the time, which becomes evidence in L7.

3. Hooks: allow, ask, or block

gate.sh checks finished work. A hook checks an action before it happens. A hook is a small program that your agent runner calls every time the agent proposes to run a command or edit a file. The hook reads a description of the proposed action and returns one of three verdicts: allow, ask, or block. This is the allow-ask-block pattern. Allow lets the action run. Block stops it and tells the agent why. Ask pauses the action until a named person says yes.

Quill is configured as a Claude Code agent, so the hook lives in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash|Edit|Write", "hooks": [{ "type": "command", "command": "./gate.sh hook" }] }
    ]
  }
}

Before any Bash, Edit, or Write action, Claude Code runs ./gate.sh hook and passes JSON on standard input: tool_name, and tool_input with command for Bash or file_path for Edit and Write. The hook decides by exit code. Exit 0 allows. Exit 2 blocks, and whatever the hook printed to standard error is shown to the agent as the reason. To ask a human, the hook prints JSON to standard output and exits 0, with permissionDecision set to ask.

Here is the hook mode of gate.sh, starting with the test lock from L3. A test lock is a rule that the agent may not edit files under tests/, enforced by a hook instead of by a polite request in plan.md.

# gate.sh hook: decide one proposed action. Called by Claude Code with JSON on stdin.
if [[ "${1:-}" == "hook" ]]; then
  input=$(cat)
  tool=$(jq -r '.tool_name' <<<"$input")
  file=$(jq -r '.tool_input.file_path // ""' <<<"$input")
  cmd=$(jq -r '.tool_input.command // ""' <<<"$input")

  # Rule 1: the test lock. Tests change only when a human changes the criterion.
  if [[ "$file" == tests/* ]]; then
    ./gate.sh log block test-lock "$tool" "$file"
    echo "BLOCKED by test-lock: Quill may not edit $file." >&2
    echo "Tests change only after a human changes the criterion in spec.md §3." >&2
    echo "Route: ask Omar to edit the test and note the change in the PR." >&2
    exit 2
  fi

  ./gate.sh log allow default "$tool" "${file:-$cmd}"
  exit 0
fi

4. A block that explains itself

A block that only says "denied" teaches nothing. The agent retries a variation, the engineer opens a chat to ask what happened, and twenty minutes vanish. A self-explaining block tells both the agent and the human four things: what was blocked, which rule blocked it, who can approve, and how to reach them. That last part is the route to approval, and it is the difference between a wall and a door.

Here is the production-delete block, added to the hook right after the test lock:

  # Rule 2: no destructive command against production without a human.
  if [[ "$cmd" =~ --env[= ]production ]] && [[ "$cmd" =~ (DELETE|DROP|TRUNCATE|--delete) ]]; then
    ./gate.sh log block prod-delete "$tool" "$cmd"
    cat >&2 <<'MSG'
BLOCKED by prod-delete: this command deletes data in production.
  Rule:     spec.md §5, human gates: "no destructive write to production without Priya."
  Approver: Priya (policy owner, payments and customer data).
  Route:    open a PR with the command in plan.md and request Priya's review,
            or post in #shelf-gates with the command and the reason.
  Instead:  run the same command with --env staging and attach the row count.
MSG
    exit 2
  fi

Read the message as Quill would. It knows what it may not do, why, and what to do next: run against staging. Read it as Omar would. He knows who to ask and where. Nobody has to open the spec to find out which rule fired, because the message cites it.

5. The decision log

Every verdict the hook returns is a fact worth keeping. The decision log is an append-only file, .gates/log.jsonl, with one JSON object per line. Append-only means lines are added and never edited or removed. Each line records the timestamp, the actor, the action, the verdict, the rule that fired, and the approver if a person was involved.

# gate.sh log <verdict> <rule> <tool> <action> [approver]
if [[ "${1:-}" == "log" ]]; then
  mkdir -p .gates
  jq -cn --arg ts "$(date -u +%FT%TZ)" --arg actor "${GATE_ACTOR:-quill}" \
    --arg verdict "$2" --arg rule "$3" --arg tool "$4" --arg action "$5" \
    --arg approver "${6:-}" \
    '{ts:$ts, actor:$actor, tool:$tool, action:$action, verdict:$verdict, rule:$rule, approver:$approver}' \
    >> .gates/log.jsonl
  exit 0
fi

Why does the log live outside any one engineer's control? Because a log that Omar can edit is a log Mr. Hale cannot trust. Locally, .gates/log.jsonl is a working copy. The copy that counts is the one CI writes to a shared bucket or attaches to the run, where nobody on the team has write access from a laptop. L6 wires that up; for now, the rule is that the authoritative log is the one produced by a timestamped run, not by a person.

6. Wiring a human gate for a production action

A production action is a command that changes real data or moves real money: a refund, a delete, a config push. A human gate is a rule in the spec that names the action, the threshold, and the person. CR-102's spec says a refund over $200 pauses for Priya. Here is how that sentence becomes a hook rule, an approval, and a log entry.

  # Rule 3: refunds over $200 pause for Priya (spec.md §5, human gates).
  if [[ "$cmd" =~ shelf\ refund ]] && [[ "$cmd" =~ --amount\ ([0-9]+) ]]; then
    amount="${BASH_REMATCH[1]}"
    if (( amount > 200 )); then
      ./gate.sh log ask refund-over-200 "$tool" "$cmd"
      jq -cn --arg why "Refund of \$$amount is over \$200: needs Priya's approval. See spec.md §5." \
        '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$why}}'
      exit 0
    fi
  fi

The sequence, end to end:

  1. Quill proposes shelf refund --gift-card GC-4471 --amount 350.
  2. The hook parses the amount, sees 350, logs an ask, and returns permissionDecision: "ask" with a reason that names Priya and the spec section.
  3. The action pauses. The reason appears in Quill's terminal, and Omar pastes it into the CR-102 PR with a request for Priya's review.
  4. Priya reads the refund reason and the original purchase amount, and approves in the PR.
  5. Omar re-runs the command with GATE_ACTOR=priya after her approval, and the hook logs an allow with approver: "priya". The refund runs once.

7. What a gate costs

Every ask has a price: the time between the pause and the approval. That is the wait-time cost, and it is the number that decides whether a gate survives contact with a real team. A gate that nobody can afford gets skipped on Fridays, and a skipped gate is worth nothing.

The decision log already holds what you need. The gap between an ask line and its matching allow line is the wait for that decision. Compute the median per rule and put it somewhere people see it, such as the PR checks summary or a weekly note.

RuleAsks last monthMedian waitAction
refund-over-2001431 minKeep; Priya answers within the hour
prod-delete32 daysKeep; rare and dangerous
export-customers94 hoursTune: Priya is out on Thursdays; add Nadia as backup approver

8. Live demo walkthrough

Here is the whole sequence for CR-102's refund, in the order you would run it at a terminal.

# 1. The checks pass before anyone looks.
./gate.sh
# ==> build ... ==> tests ... ==> lint ... ==> evals
# GATE PASSED 2026-09-14T09:30:12Z

# 2. Quill proposes the refund; the hook pauses it.
echo '{"tool_name":"Bash","tool_input":{"command":"shelf refund --gift-card GC-4471 --amount 350"}}' \
  | ./gate.sh hook
# {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask",
#   "permissionDecisionReason":"Refund of $350 is over $200: needs Priya's approval. See spec.md §5."}}

# 3. Priya approves in the PR. Omar records the approval and runs it.
./gate.sh log allow refund-over-200 Bash "shelf refund --gift-card GC-4471 --amount 350" priya
shelf refund --gift-card GC-4471 --amount 350

# 4. The log tells the story.
tail -n 2 .gates/log.jsonl

The last command prints the ask line and the allow line with approver: "priya". Attach those two lines to the PR. Mr. Hale's question in L7, "who approved this refund," is now answered by a file that was written at the moment it happened.

Summary

  • A gate is a check plus a decision: automated checks run first, and a named person decides last, on work that already passed.
  • gate.sh runs build, tests, lint, and the eval suite in that order, stops at the first failure, and exits non-zero.
  • A hook runs before every agent action and returns allow, ask, or block; the test lock and the production-delete rule are blocks, the refund over $200 is an ask.
  • A block explains itself: what was blocked, which rule, who can approve, and the route to reach them.
  • Every verdict lands in .gates/log.jsonl with a timestamp, and the copy that counts is written by CI, not by a person; the wait between ask and allow is the gate's cost, and it must be visible.

Key terms in this lesson

Check your understanding

Pick an answer to see why it is right or wrong. These are graded in code, instantly.

1.gate.sh runs its checks in a fixed order and stops at the first failure. Which order does it use, and why?
2.A hook in .claude/settings.json runs before every Bash, Edit, or Write action. What are the three verdicts it can return?
3.Which of these is a self-explaining block?
4.Why should the authoritative copy of .gates/log.jsonl be written by a CI run rather than kept on Omar's laptop?
5.Quill proposes shelf refund --amount 350. The hook returns ask. What happens next?
6.What does a gate cost, and how do you find the number?

Apply it

Write your answer in your own words. Eve grades it against a rubric and gives you feedback, the same way an LLM judge works in Lesson 5.

Short answerGraded by Eve · 5 criteriaup to ~150 words

Write the block message for a hook that stops a production database delete. It should be something both Quill and Omar can act on.

0 words
Free responseGraded by Eve · 6 criteria

Design the gate for CR-102's refund action. Cover the automated checks that run first, the hook decision for a refund over $200 and one under $200, who approves, and the log entry that records the approval.

0 words
Instructor notes(for whoever is teaching this lesson)

Talking points

  • The stale gift card delete is the thread; ask which line of gate.sh would have stopped it.
  • A gate is a check plus a decision. Checks are questions a program can answer; decisions need a person. Checks go first so people only decide about work that already passes.
  • The three verdicts are the whole surface: allow, ask, block. Students collapse ask into block. Wrong command means block; good command with a policy question means ask.
  • A block is a message, not a refusal. Four parts: what, which rule, who, how.
  • The log is evidence only if nobody on the team can edit the copy that counts. The local file is a scratchpad; the CI-written copy is the record.
  • The wait between ask and allow is the cost. Invisible cost gets skipped; visible cost gets tuned.

Live demo idea

With the lesson's gate.sh and .claude/settings.json in a repository, and no agent running yet, pipe hand-written JSON into ./gate.sh hook three times: an edit under tests/, a DELETE with --env production, and shelf refund --amount 350. Read each verdict aloud, then tail .gates/log.jsonl so the class sees three lines appear. Then run Quill on CR-101 and ask it to change the failing test; watch the block arrive and Quill edit the source file instead. Finish by computing a median wait from two fake ask and allow lines with jq.

Common misconceptions

  • "A hook and gate.sh are the same thing." gate.sh checks finished work; a hook checks one proposed action before it happens. They share a file for convenience only.
  • "Ask means the customer or the agent is asked." Ask routes to the person named in the spec's human gates.
  • "Only blocks need logging." A missing line looks the same as a gate that was switched off. Log every verdict.
  • "Exit 1 blocks." In the Claude Code hook shape, exit 2 blocks and shows stderr to the agent. Exit 1 is an error, not a decision.
  • "A line in CLAUDE.md saying 'do not edit tests' is a lock." It is a request. The test lock is the hook.

Timing

Fifty-five minutes: story and section 1, six; gate.sh, eight; hooks and the test lock, nine; the self-explaining block, six; the decision log, seven; the human gate end to end, nine; cost, four; demo walkthrough, six.

If you only have 20 minutes

Do the piped-JSON demo with all three rules (nine minutes), teach the four parts of a self-explaining block with the prod-delete message on screen (five minutes), and close with the three CR-102 log lines and the question "which line answers Mr. Hale?" (six minutes). Assign sections 2, 7, and 8 as reading.