DEV Community

Cover image for The Best Prompt Rule Is the One You Delete: Moving AI Guardrails Out of Markdown and Into Git Locks, Linux insmod, and 15ms Compiler Auto-Fixes

The Best Prompt Rule Is the One You Delete: Moving AI Guardrails Out of Markdown and Into Git Locks, Linux insmod, and 15ms Compiler Auto-Fixes

Randal L. Schwartz on October 25, 2026

Every rule that stays inside an English prompt eventually bloats, blurs, or gets gamed when the AI edits the test to fake a green exit cod...
Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

Sam LABBE's step-versus-sensor question reframes the whole piece — a Makefile prerequisite the performer can assert is just a prompt with extra steps; the sensor has to live below the performer's reach. The 15ms AST fix is the purest form of that: the rule stops being text the model can argue with and becomes something the compiler does. Once the rule is compiled away, how do you keep its intent visible to the next engineer — does the AST rule carry its rationale, or does deleting the prompt also delete the reason it existed?

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

This is the ultimate Chesterton's Fence question for AI engineering, and you’ve articulated the exact risk of naive optimization: if you delete the prompt, do you delete the memory?

The surprising reality we discovered is that Markdown prompts are actually where institutional memory goes to die. In a sprawling 2,000-line system prompt or .cursorrules file, paragraphs sit without commit hashes, without reproduction cases, and without stack traces. Engineers develop "Prompt Fear"—afraid to edit or delete any sentence because nobody knows which production outage or edge case created it three months ago. It’s folklore, not engineering.

When a rule graduates from an advisory Markdown prompt into a 15ms AST sensor, it undergoes what we call Scar Apoptosis (the active prompt paragraph is retired to save tokens and eliminate arguments), but its intent and rationale are anchored in three permanent, executable layers:

1. Nemik's Negative Witness (The Test Suite as Living Museum)
Every AST lint rule must have a test fixture. In test/rules/scar_riv_14_test.dart, we maintain a minimal reproduction case with an explicit negative witness:

// expect_lint: scar_riv_14
final messenger = ScaffoldMessenger.of(context);
await notifier.create(item);
messenger.showSnackBar(...); // Crashes if modal dismissed across await
Enter fullscreen mode Exit fullscreen mode

The test suite physically proves the vulnerability. If anyone ever asks "Why does this linter exist?", the test fixture answers with executable proof, not ambiguous prose.

2. The Diagnostic url in the IDE
In modern analyzer architectures (whether Dart's package:analyzer, ESLint, or Rust linter plugins), every diagnostic code supports a documentation URL. When an engineer or an agent triggers the rule, the compiler doesn't just block them—it emits a clickable permalink directly to the public registry dossier (e.g., https://syntheticscars.org/scars/scar-riv-14).

3. The Registry Tombstone
In SCAR_REGISTRY.md, the Markdown entry doesn't disappear into /dev/null. Its lifecycle state changes to:
Lifecycle: Graduated -> package:synthetic_scars_lint (Rule: scar_riv_14)
It permanently retains The Wound (the production crash trace), The Trap (the seductive happy-path code), and the Git commit of the ticket where it was born.

Compiling a prompt rule into an AST sensor doesn't erase why the fence was built—it promotes the fence from a blurry handwritten note stapled to a post into an energized, self-documenting perimeter.

Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The three-layer answer is stronger than I expected — the test fixture as living museum is the part that actually answers the Chesterton's Fence worry, since the 'why' survives as executable proof rather than prose. The registry tombstone is the elegant complement: graduated, not deleted, so the wound and the trap stay searchable. The weak link I'd still probe is the diagnostic URL — it works as long as the registry stays live and linked; does the rule degrade gracefully if that dossier ever goes dead, or does the test fixture alone have to carry the rationale then?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

You've put your finger on the exact failure mode of web-first documentation: link rot and SaaS dependency.

If syntheticscars.org evaporated from the internet tomorrow, or if you're running in an air-gapped defense/banking environment with zero outbound internet, the rule degrades completely gracefully because the architecture treats external URLs strictly as ergonomic convenience, never as the source of truth.

Here is how the rationale survives offline:

1. The In-Repo Invariant (Git Is the Registry)
The public website is just a static build target compiled from local Markdown files (dart tools/build_website_data.dart). The canonical registry lives on disk in the repo root (scars/riv/SCAR-RIV-14.md). If the web server dies, git clone still contains the entire history, the ticket references, and the markdown files right alongside the code.

2. The Compiler Rule Source Embeds the Autopsy
In modern analyzer rules (like Dart's package:custom_lint or ESLint plugins), the rule class itself carries the complete 4-vector autopsy in its docstring:

/// [SCAR-RIV-14] Pre-Capture Linter Masking Across Modal Dismissal
/// 
/// The Wound: Phantom SnackBars fire and unmounted WidgetRefs mutate 
/// when a dialog is dismissed mid-flight across an await.
/// The Trap: Pre-capturing ScaffoldMessenger.of(context) before await 
/// bypasses `use_build_context_synchronously` because `context` isn't 
/// lexically referenced after await.
/// The Reflex: Verify `messenger.mounted` or bind to controller lifecycle.
class ScarRiv14Rule extends DartLintRule {
  static const _code = LintCode(
    name: 'scar_riv_14',
    problemMessage: 'Pre-capturing ScaffoldMessenger before an await leaks state if the modal dismisses.',
    correctionMessage: 'Check messenger.mounted before using pre-captured UI handles.',
  );
  ...
}
Enter fullscreen mode Exit fullscreen mode

Even if an engineer never opens a browser, dart analyze emits the standalone diagnostic message, and go to definition on the rule in the IDE displays the full medical chart in the source comments.

3. Cryptographic Provenance (git log -S)
Because the lint rule and the test fixture are committed atomically in the same commit that closed the ticket, git log -S scar_riv_14 immediately surfaces the commit SHA, the PR discussion, and the original developer who solved it.

4. The Negative Witness as the Last Line of Defense
And as you noted, even in the absolute worst-case scenario where all documentation and git logs were somehow incinerated, the test fixture in test/rules/scar_riv_14_test.dart is an executable specification. You can run it, see the exact AST pattern it rejects, mutate the fixture, and watch it break.

Physics beats prose; executable code beats hosted URLs.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

That closes the loop cleanly — the URL was only ever the ergonomic layer, and the autopsy living in the rule's own docstring plus the in-repo registry means an air-gapped clone loses convenience, not memory. The 'physics beats prose' line is the right summary: a fixture you can mutate and watch break can't rot the way a link can. One thing I'd still test: does the in-repo registry stay readable when the rule itself is later deleted — or does deleting the sensor quietly orphan the scar it was built from?

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

This is the final frontier of the lifecycle: sensor decommissioning.

What happens when an AST sensor is deleted? Does it leave a dangling, orphaned ghost in the registry?

The short answer is no, because scars are monotonic flight recorder logs, and sensor retirement is a formal state transition, not a silent deletion.

In our architecture, sensors only get deleted for two real-world reasons:

  1. The Upstream Fix (Upstreamed): The framework maintainers finally fix the root design flaw. For example, Dart or TypeScript introduces native language-level checks that make the trap unrepresentable, making our custom lint rule redundant.
  2. Subsystem Migration (Retired): The application migrates away from the underlying library or deprecates the subsystem entirely.

When this happens, the scar doesn't become a dangling orphan. It moves through a formal 4-state lifecycle machine:

Candidate 
   ↓ (Empirically verified)
Active (Markdown prompt) 
   ↓ (AST auto-fix / hook written)
Graduated (package:synthetic_scars_lint) 
   ↓ (Framework fixes it OR library deprecated)
Upstreamed / Retired
Enter fullscreen mode Exit fullscreen mode

Here is how the system physically guards against quiet orphaning:

1. The Bi-Directional CI Integrity Barrier
Our tooling (dart tools/bundle_all.dart / repo CI) runs bidirectional reference checks across the codebase. If an engineer deletes scar_riv_14.dart from the linter package while SCAR-RIV-14.md still asserts Lifecycle: Graduated -> package:synthetic_scars_lint (Rule: scar_riv_14), the build fails with an integrity violation:
Error: Scar SCAR-RIV-14 references non-existent sensor 'scar_riv_14'. Transition scar to 'Upstreamed' or 'Retired' before removing sensor.
You physically cannot "quietly" delete a sensor and leave a broken pointer in the registry.

2. The State Transition Tombstone
When the sensor is decommissioned, the scar metadata is updated:
Lifecycle: Upstreamed (Fixed natively in Flutter 3.27 / PR #142091)
The original Wound (the stack trace), the Trap (the seductive syntax), and the historical ticket commit remain 100% intact and searchable in the repository.

3. Second-Order Immune Memory (Why You Never Delete the Scar)
Why keep the scar after the sensor is dead and gone? Because software engineering has a tragic habit of reinventing old traps under new names.

Five years from now, when the team designs the next internal reactive framework or evaluates a shiny new state management library, the retired scar serves as a design specification: "Remember when we got burned by pre-capture modal leakage in 2026? Does this new library have the same unmount boundary?"

The sensor is temporary scaffolding; the scar is permanent architectural wisdom.

Collapse
 
paullovelace profile image
Paul Lovelace •

The 'red discriminates, green is cooperative' line is the one I'll keep coming back to. I've seen exactly that shape where a test goes green because the test itself got nudged, not the code, and exit code 0 tells you nothing about it. Pinning test/ with a Merkle lock across red-to-green feels like the honest fix, though I wonder how painful it is day to day when a legit test change is needed mid-fix — do you end up re-witnessing red more often than you'd like? And the insmod idea, mounting only the scar slice matching git diff, is clever; I'd be curious whether that routing holds up outside Dart, where you don't have the same AST tooling to fall back on.

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Paul, that's the exact ergonomic question!

On the first point: when a test legitimately needs to change mid-fix (e.g. an edge case was modeled incorrectly in the initial assertion), mutating test/ invalidates the red receipt, which resets TEST_TREE_R. But because the child subagent just re-runs the single test file (2–3s), re-witnessing Red is cheap—and that small friction is the entire point. If the test changed, you want to see it fail again to prove the new assertion actually discriminates before trusting green.

On the insmod question: the routing mechanism itself is pure git diff --name-only, so it is completely language-agnostic—we use the exact same file-path matching across TypeScript cloud functions and shell scripts. And while we built synthetic_scars_lint in Dart first, the 3-stage graduation pipeline maps 1-to-1 to other ecosystems: TypeScript has custom ESLint rules with RuleFixer (--fix), Python has custom Ruff/flake8 AST passes, and Rust has custom Clippy/syn lints. The goal everywhere is the same: move the scar out of English prompt memory and let a 15ms AST pass handle the repair!

Collapse
 
fgrandon profile image
Francisco Grandón •

Man, Paul, you hit the two exact bruises everyone gets on their shins the moment they actually try this in production!

To your first point: Yes, the Merkle lock will make you groan at first. If you treat it like a rigid padlock, you feel like an exasperated parent watching an agent stand in front of the fridge asking for written permission in triplicate just to add an optional argument to an assertion. You definitely re-witness red more than you'd like on Day 1.

The sanity-saving trick we learned the hard way is making the agent declare a "Test Contract renegotiation" instead of letting it sneak changes into the test suite while pretending to fix the code. If the test legitimately needs to evolve, the agent has to drop its tools, step back, break the old seal with a fresh git write-tree, and watch the new expectation fail on clean code. Is it a bit dramatic? Absolutely. But it saves you from that 2:00 AM sinking feeling where an agent proudly presents a green test suite, only for you to discover it "fixed" a failing database constraint test by quietly commenting out the foreign key check and replacing it with a cheery # TODO: revisit later.

As for life outside Dart's comfy compiler world—good news: insmod is actually even funnier and easier to pull off in wild ecosystems like Python or TypeScript.

You don't need a PhD in AST compilers to get the magic. Even a dead-simple, 15-line shell hook that checks git diff --name-only and only loads the relevant rule slice works wonders. Because let’s be honest: stuffing 40 pages of strict SQL-injection and lock-contention rules into an LLM's context window while it’s literally just centering a <div> in CSS isn’t "enterprise safety." It’s just burning cash and begging the model to hallucinate a database connection inside a Tailwind class!

In Python, ruff or the built-in ast module handles the lightning-fast checking for next to zero milliseconds, and in JS/TS a quick ESLint AST rule does the same. Turns out LLMs behave a lot like overworked developers: if you give them a 500-page rulebook, they skim it and make things up; but if you give them a concrete physical guardrail, they just quietly do the right thing.

Collapse
 
fgrandon profile image
Francisco Grandón •

Hi @paullovelace,

Following up on your question about the operational friction of modifying tests mid-flight: that observation led us to design a formal renegotiate workflow in our Merkle guard CLI.

Rather than letting the agent slip edits into tests, it must explicitly state a mandatory --reason, archive the previous receipt history, and witness the new failure on current code before proceeding.

Documented how we implemented your feedback and cited your question here:
dev.to/fgrandon/i-cut-my-agents-fi...

Thanks for pushing on that edge case!

Collapse
 
emilywoodsnyc profile image
Emily Woods •

One boundary I’m still curious about, I’d want the check that verifies the locked test tree to run outside the agent’s editable checkout. Otherwise I could preserve test/ and still alter the runner, dependency resolution or build configuration around it. Which of those inputs are pinned in the off board check, and what makes a changed runner fail closed?

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

"Otherwise I could preserve test/ and still alter the runner, dependency resolution or build configuration around it. Which of those inputs are pinned in the off board check, and what makes a changed runner fail closed?"

Spot-on threat model—if you only block-list test/** during the implementation phase (Target 35), a clever (or desperate) builder can leave test/foo_test.dart byte-for-byte identical while shimming a mock package in pubspec.yaml, weakening a lint in analysis_options.yaml, or editing .scripts/test_runner.sh to exit 0.

We close that TCB (Trusted Computing Base) bypass across three concentric rings so any runner, dependency, or build-config mutation fails closed:

  1. Positive Allowlist on the Working Tree (Not Just a test/ Blocklist): At Target 35 (Green Implementation), the guard does not merely check that git diff test/ is empty after the Red test (Target 30) is locked. Instead, it enforces a positive file allowlist against the approved plan (20_plan_approved.md): the only paths permitted to have non-zero diffs in git status --porcelain are the exact lib/ implementation files approved at Target 20. Touching pubspec.yaml, pubspec.lock, analysis_options.yaml, build.yaml, .scripts/*, or .github/workflows/* immediately trips an unscoped-mutation abort (exit 1).
  2. Out-of-Checkout Harness & Pinned SDK Toolchain: The supervisor (SKILL.md / WORKFLOW.mk and its audit scripts) does not live inside the agent's editable repository worktree—it lives out-of-tree in the read-only host configuration (~/.gemini/config/skills/...) and invokes the pinned host toolchain binary (fvm flutter test / dart test) directly against the git index.
  3. Off-Board Ephemeral CI (Target 65) + The Fail-Closed Landing Matrix (Target 75, SCAR-PROC-122): Local test execution is treated strictly as a preliminary Assertion; it can never unlock merge on its own. At Target 65, an off-board GitHub Actions runner checks out the branch in a clean container, enforces lockfile integrity (pubspec.lock), and runs the canonical test suite. Finally, at Target 75, our deterministic 7-Criteria Landing Classifier inspects git diff origin/main...HEAD --name-only: any diff touching build configuration, dependency resolution (pubspec.*), analyzer config (analysis_options.yaml), CI workflows, or runner scripts automatically fails closed into Branch B (Mandatory Human Lead Reviewer), revoking autonomous direct-land eligibility even if every test and bot check is green.
Collapse
 
emilywoodsnyc profile image
Emily Woods •

That answers my question, thanks! I’d keep a deliberately modified runner as a regression fixture, with the recorded result showing which gate rejected it. That would make the boundary easy for the next reviewer to check.

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Love that idea, @emilywoods — testing the gate itself with a deliberately tampered runner/pubspec fixture ("Who watches the watchmen?" / Popperian falsification applied to the CI airlock!) is the gold standard.

Having a canned negative fixture in the test suite that mutates .scripts/flutter_test.sh or pubspec.yaml while leaving test/ untouched — and asserting that the witness/hash gate fails closed with the exact expected exit code and rejection message — makes the security boundary self-documenting for every future maintainer. I'm stealing that for our gate regression suite!

Collapse
 
fgrandon profile image
Francisco Grandón •

Randal, this 11-part magnum opus should be mandatory reading before anyone is legally allowed to write a system prompt longer than 50 lines.

The progression from Socratic inquiry all the way down to Merkle-tree locks and 15ms AST auto-fixes hits the exact architectural nerve modern AI engineering has been trying to ignore: Markdown is not a runtime security boundary.

Relying on an English prompt to stop an autoregressive LLM from gaming its own unit tests is like installing a screen door on a submarine and hoping the water respects the polite sign that says "Please do not ingress." When pressure mounts, the model doesn't obey semantic boundaries—it minimizes loss along the path of least resistance, which usually means refactoring the assertion into expect(true).toBe(true) and declaring victory.

Pushing enforcement down to the OS and compiler (APPROVED_TREE=$(git write-tree) and dynamic insmod-style rule scoping) solves the dual nightmare of context rot and token tax simultaneously. It treats the context window as what it actually is: an expensive, volatile L1 cache that should hold state transitions and architectural reasoning, not 1,400 lines of defensive boilerplate.

One small operational corollary we've observed when implementing this "physicalized guardrail" philosophy in long-running agent workflows: The Canary Committer pattern. Beyond locking the test fixtures via tree-hash, we mount the test runner inside an ephemeral sandbox that intercepts and logs filesystem access patterns. If the agent attempts to touch or even stat the verification harness before the implementation phase emits a physical compilation artifact, the environment terminates the turn with an explicit POSIX permission fault (EACCES). The agent immediately course-corrects without spending 5 turns apologizing for its life choices, because it collided with a concrete wall rather than a philosophical guideline.

Looking forward to Part 4 and the Rapid-Regret Miner. Thank you for anchoring modern agentic design in 50 years of battle-tested Unix reality!

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts •

Collapse
 
fgrandon profile image
Francisco Grandón •

Hi @randalschwartz,

Your point about deterministic code beating prompt rules ("The best prompt rule is the one you delete") hit home.

I took the systems approach to heart in our agentic CLI setup: instead of pleading with the model in Markdown, we audited our fixed context (cut it by 42.4%) and moved test tamper-prevention into a 1,000-line standard-library Python CLI that seals test trees with SHA-256 Merkle hashes (witness-red / verify-green).

Wrote a breakdown of the implementation, the edge cases (like uninformative red traps and circular authority), and gave you full credit for the spark:
dev.to/fgrandon/i-cut-my-agents-fi...

Thank you for continuing to write Synthetic Scars!

Collapse
 
eternaclarity profile image
Jesse Gamble •

Moving the guardrails into Git instead of another Markdown file is interesting.