An agent refactored my dashboard last week. Tests passed. The diff looked clean. Two days later I found an unescaped innerHTML injection and a sort that changed order between runs.

Neither bug was exotic. Both were exactly the class of flaw LLMs produce: plausible code that typechecks, passes happy-path tests, and fails quietly in production. Manual review caught it by luck, not by process. Luck does not scale to autonomous loops.

This post is the architecture I replaced luck with: a parse-first validation gate that reads agent output as an immutable AST before anything executes.

1. The Problem: Subtle Runtime Flaws From Autonomous Refactoring

Across one week of agentic refactoring, the same categories kept recurring:

Flaw How it ships How it fails
Unescaped innerHTML Template string interpolated with data Stored XSS; payload arrives later via config
Non-deterministic sort sort-by on a tie-heavy key Same input, different output order per run
Substring keyword match str/includes? for “manager” Matches “managerial” — wrong ranking, no error
Malformed config EDN with an unexpected top-level shape Parse succeeds, first consumer throws at runtime

The common property: none of these fail at parse time in the agent’s language of choice. JavaScript evaluates the template string happily. Clojure sorts the vector happily. The failure is deferred until data — real data — arrives.

Deferred failure is the enemy of autonomous loops. An agent that runs overnight needs the gate to fire at write time, not at incident time.

2. The Architectural Solution: Parse Before Trust

Instead of relying on manual code review, wrap the agentic loop with a strict, immutable validation gate. The gate has one rule: nothing executes until it has been parsed into an immutable structure and checked against static invariants.

Diagram

Two properties matter more than the specific checks:

Immutability. The parsed value is never mutated. Every downstream check reads the same structure the parser produced. When a check fails, the diagnostic references the exact value the agent wrote — not a value that three transformations have quietly reshaped.

Determinism. The gate is a pure function of the diff. Same input, same verdict, same error message. An agent that fails validation can read the error, fix the cause, and resubmit — the loop converges because the oracle does not move.

3. Technical Execution: Babashka and the EDN Reader

The pipeline is Babashka. Clojure’s EDN reader is the load-bearing piece: it converts configuration text into an immutable data structure before any code touches it, and it fails loud on malformed input.

The validator is ~40 lines, reads-only, and throws on the first violated invariant:

(require '[clojure.edn :as edn])

(defn read-edn! [path]
  (try (edn/read-string (slurp path))
    (catch Exception e
      (throw (ex-info (str path " is not valid EDN: " (.getMessage e))
                      {:path path})))))

(defn assert! [ok message]
  (when-not ok (throw (ex-info message {}))))

(let [rules (read-edn! "config/rules.edn")
      bank  (read-edn! "config/story-bank.edn")
      skills  (get-in rules [:resume-profile :skills])
      stories (:stories bank)]
  (assert! (map? rules) "rules.edn top-level must be a map")
  (assert! (and (vector? skills) (seq skills))
           "skill groups must be a non-empty vector")
  (assert! (apply distinct? (map :id skills)) "skill ids must be unique")
  (assert! (apply distinct? (map :id stories)) "story ids must be unique")
  (doseq [s stories]
    (assert! (every? string? (:keywords s))
             (str "story " (:id s) " keywords must be strings"))))

This is the AST-level discipline in miniature. edn/read-string gives a tree; the assertions walk the tree and enforce the contract at the root, before any consumer exists. A malformed top-level shape dies in the validator with a named field and a story ID — not three layers deep in a dashboard render at 2 a.m.

The same gate catches the string-matching disease statically. A keyword list containing bare "manager" gets flagged in review, because the matcher downstream is boundary-aware — and the invariant that enforces which matcher is used lives in one function, not scattered across call sites.

The Merge Barrier

The gate only works if it is non-negotiable. bb test chains the validators first, then the suites:

EDN validation:      PASS  (shape, uniqueness, types)
TSV schema check:    PASS  (432 rows, 11 columns)
Scorer tests:        PASS  (14 tests, 21 assertions)
Story-bank tests:    PASS  (8 tests, 54 assertions)
Working tree:        clean

The diff audit is the second half. git diff --check on every commit; whitespace findings in generated data files are classified, not ignored — a trailing-tab pattern in a TSV is a schema signal, not noise. Every fix lands with a regression test pinned to the exact failure: the unescaped interpolation got an escaping helper plus a test; the unstable sort got a deterministic comparator plus a test that asserts two runs produce byte-identical output.

That last test is the one worth stealing: render the same artifact twice, assert the outputs are equal. It costs one line and kills an entire class of “works on my machine” flakiness that no linter catches.

What the Gate Caught

First week of operation, real findings from agent-proposed changes:

Finding Gate that caught it
Story text interpolated into innerHTML unescaped Escaping invariant + render test
Ties in story ranking resolved non-deterministically Double-render equality test
str/includes? matching “managerial” for “manager” Boundary-aware matcher + negative test
/api/stories listing directories as packs Regular-file filter + route test
Path traversal via encoded ..%2f in pack names Decode-then-reject route guard

None of these were caught by reading the diff. All of them were caught by executing the gate. The diff review’s job is intent; the gate’s job is truth.

4. Key Takeaway

Autonomous AI agents require deterministic, programmatically enforced guardrails — not just prompt engineering.

Prompt engineering shapes what an agent tries. Guardrails determine what an agent ships. An autonomous loop without a deterministic gate is a random walk with good vocabulary. The gate does not need to be sophisticated — a parser, a handful of structural invariants, a deterministic test suite, and a barrier that cannot be talked past. What it needs to be is immune to negotiation: same diff, same verdict, every time.

That is the whole design. Parse first. Check the tree. Never merge on a maybe.