DEV Community

Cover image for Evidence-Driven Development: Give Your Coding Agent Something to Prove
Don Johnson
Don Johnson Subscriber

Posted on AI-assisted

Evidence-Driven Development: Give Your Coding Agent Something to Prove

An AI coding agent finishes the feature. The tests pass. The explanation sounds reasonable.

Then someone asks a small question: “What happens if I run that request again?”

That question can change the shape of a project. Now “done” needs to mean something observable. The answer needs an experiment, and the experiment needs a way to be wrong.

This tutorial builds a tiny task list around that idea. By the end, you will have a working application, two deliberately broken versions, and a folder of evidence showing which promises each version keeps.

I use evidence-driven development to describe this working habit: write down a claim, decide what would disprove it, run the relevant experiment, and let the result govern the next decision.

The coding agent can help at every step. The evidence must remain inspectable without trusting the agent's summary.

First, some credit for the idea

The name predates coding agents. Jan Bosch's 2017 book, Speed, Data, and Ecosystems, includes an Evidence-Driven Development chapter. Its contents connect requirements, hypotheses and experiments. I'm using the term for a practical agent-assisted workflow, without claiming to have invented a methodology. Publisher's catalog.

There is related work in AI, too. Xia and colleagues describe evaluation-driven development and operations, or EDDOps, as a continuing feedback loop for LLM agents. Their paper addresses a broader agent lifecycle than the small application we will build here. EDDOps paper.

TDD still gives us a useful red–green–refactor loop. BDD helps express behavior through examples people can discuss. In this tutorial, EDD names the discipline of keeping the claim, execution conditions, observed result and resulting decision connected. Those practices fit together. TDD, Given–When–Then.

A small application with a real promise

Our app is Tiny Tasks. It adds tasks and lists them. Python and SQLite are enough; the application and experiment runner use only the Python standard library. Use Python 3.10 or newer with SQLite support.

The companion repository contains the complete source, cover artwork and recorded evidence. Clone it to follow along:

GitHub logo copyleftdev / evidence-driven-development

A runnable evidence-driven development tutorial: falsifiable claims, SQLite retries, negative controls, and reproducible results.

Evidence-Driven Development: Tiny Tasks

A standalone companion project for Evidence-Driven Development: Give Your Coding Agent Something to Prove. A tiny local task list teaches explicit claims, negative controls fresh-process experiments, nonzero exercise gates and reproducible evidence.

Evidence-Driven Development: Give your coding agent something to prove.

Run

Requires Python 3.10+ with its standard SQLite module. No application dependencies, API keys, model account, network service or cloud resources are required.

git clone https://github.com/copyleftdev/evidence-driven-development.git
cd evidence-driven-development
python3 -m tiny_tasks add "Water plants" --request-id plant-1
python3 -m tiny_tasks add "Water plants" --request-id plant-1
python3 -m tiny_tasks list
python3 -m unittest discover -s tests -v
python3 scripts/prove.py
Enter fullscreen mode Exit fullscreen mode

The second create returns the original task with created: false. Use a new request ID for a genuinely new action. Titles are compared after trimming surrounding whitespace Default data: .local/tasks.sqlite; set a different file using --db PATH before the add or list subcommand. Conflicting request reuse and invalid input return…


git clone https://github.com/copyleftdev/evidence-driven-development.git
cd evidence-driven-development
git checkout 1f03111db02d359b24602992eddae60122d49d74
python3 -m tiny_tasks add "Water plants" --request-id plant-1
python3 -m tiny_tasks list
Enter fullscreen mode Exit fullscreen mode

The checkout selects the source revision used in this article. The add command returns a task. The list command starts a new process and reads it back. The default database lives at .local/tasks.sqlite.

The request ID belongs to the user's intended action. When the caller retries that same action, it sends the same ID. A genuinely new task gets a new ID—even if the title happens to match.

That small distinction gives us something worth proving.

1. Write a claim that can lose

“Make the task list reliable” leaves too much room for interpretation.

Here is the experiment contract we wrote before implementing the application:

Claim A result that disproves it
Acknowledged tasks survive a process ending A new process cannot read the saved task
Repeating a create request returns the original task A retry creates another task or returns a different ID
A request ID cannot silently acquire a different meaning Reusing it with a different title succeeds or changes saved data
Invalid input leaves the list alone An empty title adds a task

The complete contract is embedded here, with a commit-pinned copy in the repository:

The repository records those claims in experiments/001-reliable-tasks/experiment.json. It also declares three restart rounds, eight concurrent retry processes, two independent repetitions, and the scenarios that must actually execute.

These numbers are a small teaching workload. They are not a statistical reliability estimate or a throughput target.

Before asking an agent for code, give it this kind of task:

Build a local task list that adds and lists tasks.

First propose observable claims and the outcomes that would disprove them.
Preserve acknowledged tasks across fresh processes.
Bind each create request ID to one normalized title and one task.
Exercise concurrent retries and conflicting reuse.

Keep the application small. Use local synthetic data.
Do not claim properties the experiment does not exercise.
Enter fullscreen mode Exit fullscreen mode

Review the claims yourself. An agent that quietly narrows “survives interruption” into “works twice in the same object” can produce a beautiful test for the wrong promise.

2. Give the agent a place to leave evidence

The project has a few small documents with distinct jobs:

AGENTS.md                         working rules and commands
docs/plan.md                      task progress and handoff
docs/decisions.md                 decisions and their reasons
experiments/001-reliable-tasks/
  experiment.json                claims written before code
  README.md                      findings and limitations
  runs/<run-id>/                  actual outputs and source hashes
Enter fullscreen mode Exit fullscreen mode

AGENTS.md tells an agent where these things live. The experiment file defines the claim. The run directory records what happened. The decision document explains what we chose because of it.

You can start with a short instruction:

Read the experiment contract before changing behavior.
Run the declared scenarios and keep failed outputs.
Create a new run directory; never replace an earlier run.
Report which claims passed, which failed, and what was not tested.
Enter fullscreen mode Exit fullscreen mode

Instructions need maintenance. If a useful constraint already lives in a canonical document, link to it. A growing pile of repeated instructions can make the next agent's job harder. OpenAI's current guidance similarly favors focused instructions and task-specific context. Guidance on skills and project instructions.

3. Implement the smallest useful design

Tiny Tasks stores each task alongside its request ID. A unique database constraint prevents two stored rows from sharing that ID.

Creation uses one transaction. It looks for the ID, compares the normalized title if the ID exists, and inserts only when it is new. The command returns success after the transaction commits.

The complete tiny_tasks/store.py implementation is embedded below. You can also read the commit-pinned source.

“Normalized” has an explicit meaning here: the app removes leading and trailing whitespace before comparing titles. The add method shows the transaction boundary; validation, connection setup and listing are included so you can inspect the whole example.

That boundary matters because checking in one transaction and inserting later would leave room for another process to intervene. SQLite documents the write-transaction behavior of BEGIN IMMEDIATE in its transaction reference. Still, plausible code is only a candidate explanation. We have to run the workload we promised to support.

4. Cross the boundary you are making a claim about

A test can create a Store, add a task and immediately list it. That is useful. It does not establish what happens in a new process.

Our experiment runner invokes the actual CLI in subprocesses. Every command starts fresh. For the restart scenario, it adds three tasks in sequence, checking after each successful create that a separate process can read the expected list.

For retries, it exercises two different situations:

  • A create succeeds, its caller ignores the response, and a fresh process repeats the request.
  • Eight processes submit the same request, launched through a synchronization barrier.

The second scenario checks the returned identities, the number of created: true responses and the rows actually saved. Exactly one task should exist.

The ignored response is a controlled stand-in for a caller that is unsure whether an action succeeded. We did not cut a network connection or kill a process during a commit. Keeping that distinction visible makes the result useful.

5. Break the application on purpose

Now comes my favorite part: establish that the experiment can catch the defect it claims to detect.

The lab/ directory contains two intentionally wrong implementations. They use the same command shape as the real app:

  • Volatile: returns successful creates but keeps no durable tasks.
  • Duplicates: saves tasks but ignores request identity, inserting a row on every retry.

These are negative controls: known-bad examples that the checks must reject for a specific reason. They are teaching fixtures, not bugs we are pretending to have discovered accidentally.

The volatile version must fail the restart claim. The duplicate-accepting version must fail the concurrent retry claim. A syntax error in a broken version would not establish either property, so inspect the saved responses and failure reasons.

Review caught a weakness in the first version of the gate: it accepted a control's failure without checking its reason. A child-process crash could have masqueraded as a successful bug detection. We added regression tests, required nonzero exercise counts for the controls, and made the gate check the targeted failure reason. The earlier run remains in the repository, with that gate limitation recorded.

There is another way to get a misleading green result: execute nothing.

Our runner tracks how many times each required scenario ran. This intentionally incomplete report must fail even though every claimed outcome says true:

empty = {
    "claims": {name: True for name in required_scenarios},
    "exercised": {name: 0 for name in required_scenarios},
}
Enter fullscreen mode Exit fullscreen mode

A check that refuses zero activity is an exercise gate. It answers a basic question before interpreting success: did we actually attempt the scenarios our conclusion depends on?

6. Run it and keep the receipts

From the companion project:

python3 -m unittest discover -s tests -v
python3 scripts/prove.py
Enter fullscreen mode Exit fullscreen mode

The first command runs nine tests: six for application behavior and three for the harness. The second runs the process-boundary experiment, both negative-control implementations and the empty-exercise control.

In the recorded run, the results were:

Implementation Restart Retry after ignored response Concurrent retry Conflicting reuse Invalid input
Volatile control Fail Fail Fail Fail Pass
Duplicate-accepting control Pass Fail Fail Fail Pass
SQLite candidate Pass Pass Pass Pass Pass

Each implementation ran twice. The pass/fail outcomes and exercise counts matched between repetitions. The SQLite candidate exercised three restart rounds, one ignored-response retry, eight concurrent submissions, one conflicting reuse and one invalid-input scenario per repetition.

The two controls failed where intended. The report with zero exercised scenarios was rejected. Those facts allow the overall experiment gate to pass.

You will find a new directory under experiments/001-reliable-tasks/runs/. It contains:

  • A manifest with Python and SQLite versions and hashes of the application, harness and contract.
  • A response trace and scenario outcomes for each implementation and repetition.
  • A summary with the overall decision and its scope.

The captured run used for this article is 20261010T042729Z-633b0705. Your run will have its own ID. The project preserves the original run directory so you can inspect the evidence behind this table, including its summary and source manifest.

The hashes help identify the source files used in a run. They do not establish who performed it, and someone who can rewrite both the files and their manifest can forge a matching set. This is reproducible local evidence, not a signed attestation system.

7. Make a decision the evidence supports

We can now choose SQLite for this local application's declared process and retry behavior.

We have not tested power failure, a corrupt disk, a network filesystem, large task volumes, security boundaries or sustained throughput. We have not measured how often different coding models produce a correct implementation. Those would be separate questions with different experiments.

This is also where you resist rounding a result into the outcome you wanted. If a target is missed, keep the result and the original threshold visible. A follow-up experiment can ask a better question; it should have a new identity.

A useful agent handoff looks like this:

Implemented: persistent task creation and payload-bound retries.
Verified: the recorded process-boundary scenarios and negative controls.
Evidence: the named run directory and source manifest.
Not tested: power loss, disk corruption, throughput or model reliability.
Decision: use this implementation for the documented local scope.
Enter fullscreen mode Exit fullscreen mode

That is enough for another developer—or another agent—to pick up the work without inheriting an unsupported “everything works.”

What changes when agents do more of the work?

An agent can propose the claims, build the application, write the harness and summarize the run. That is convenient, but all four can share the same mistaken assumption.

Give the claim a review before implementation. Inspect the failure cases. Keep an external observation boundary: a subprocess, an HTTP client, an independently written reference calculation, or a recorded user journey appropriate to the property. Check the actual artifacts when an agent reports success.

A second model may help review the work. Its approval is another judgment to examine. For this tutorial, deterministic process results carry the decision; no model judge is needed.

The app itself does not use an LLM. That is deliberate. You can practice this development workflow with your preferred coding agent while keeping the example's behavior easy to inspect and its runs inexpensive to repeat.

Try it on your next feature

Choose one promise your users care about. Describe an observation that would prove it wrong. Run a test that crosses the relevant boundary. Feed that test a known-bad case. Save the result with enough context that somebody else can challenge it.

Then ask your coding agent for the evidence directory.

Top comments (8)

Collapse
 
pm25coder profile image
pm25coder •

I ran the pinned checkout (1f03111) and spent the time on the control half rather than the claim half, because that is where the teeth are. scripts/prove.py gates on control_rejected, which requires each broken arm to fail one named scenario with one exact message — volatile → restart, duplicates → concurrent_retry. Clean property. What it does not require is that every claim has some arm that can break it, and one of your four claims has none.

lab/broken_tasks.py imports the real validator — from tiny_tasks.store import validate — and calls it before the mode branch, for both modes. So both broken arms share the good validation path. Running the validation scenario (empty title) against every recorded arm:

sqlite      -> rc=2 {"error":"invalid_input","message":"title must contain 1 to 120 non-NUL characters"}
volatile    -> rc=2 {"error":"invalid_input","message":"title must contain 1 to 120 non-NUL characters"}
duplicates  -> rc=2 {"error":"invalid_input","message":"title must contain 1 to 120 non-NUL characters"}
Enter fullscreen mode Exit fullscreen mode

The claim "an empty title fails without adding a task" passes in all three. restart, retry and conflict each have an arm that makes them lose; validation is the one whose disproof your own table names ("an empty title adds a task") but no arm produces. So the gate demonstrates falsifiability for three of four claims and asserts it for the fourth — which is the move the post warns about, one level up: a claim that cannot lose reads like a claim that holds.

It is a one-arm gap, not a wrong number. A third mode in broken_tasks.py that skips validate() and inserts closes it: add " " then returns rc=0 {"created":true,"task":{"id":1,"title":" "}} and list shows the row, so the harness's own validation() assertions (status == 2 and response.get("error") == "invalid_input" → "blank title was accepted"; listing(db) == [] → "invalid create added a task") fail the same way the other two controls fail theirs. Then the gate checks what it looks like it checks: every claim exercised and falsifiable in the recorded apparatus, not just the three you happened to write a control for.

The recorded-before-implementation contract is the part I would keep either way — I just want the fourth row to be able to come out red.

Collapse
 
ascendra_ventures_92c21f2 profile image
Ascendra Ventures •

The question about running the request again is a good test of whether "done" means anything. A lot of agent output passes its own tests and still fails that kind of question, because nobody wrote down what would prove it wrong.

I like that your evidence folder outlives the agent's summary. On client work we do something similar at the contract level: each piece of work is one problem with acceptance criteria written before the build starts, and the handover includes the checks that show each criterion holds. Idempotency, retries and bad input usually end up on that list, because they are exactly where a confident summary hides gaps.

The deliberately broken versions are the part I would borrow. A check that has never failed has not really been tested. Did the agent get better at proposing disproving experiments once it had seen a few examples, or did you still write most of them yourself?

Collapse
 
traceypears profile image
traceypears •

Thực tế mình áp dụng cách này khi review PR của team: yêu cầu kèm theo một file repro.md ghi lại steps to reproduce, expected vs actual behavior, và command để chạy test case minimal. Lúc đầu mọi người ngại vì "tốn thời gian", nhưng sau vài lần rollback production vì bug không reproduce được local thì thói quen này tự định hình.

Điểm mấu chốt là ép mình viết test case trước khi fix — không phải để pass CI, mà để có evidence cụ thể cho lần debug sau này. AI agent sau này đọc được file này sẽ hiểu context nhanh hơn đọc commit message mập mờ — found it via LabAgent, site: labagent .tech

Collapse
 
baumgaerben profile image
baumgaerben •

Quy trình này nghe rất giống "test-driven debugging" nhưng mở rộng ra toàn bộ vòng đời task — thay vì chỉ viết test cho bug, ta viết task list như một suite of assertions về behavior mong đợi. Lợi ích lớn nhất mình thấy: khi agent (hoặc teammate sau này) fail ở step 3, evidence từ step 1-2 đã cho context đủ để reproduce mà không cần đoán mò.

Một pattern mình hay áp dụng: mỗi task item kèm theo verify: — command cụ thể để verify step đó done (vd curl -s localhost:3000/health | jq .status). Khi break on purpose, agent chỉ cần run verify command của step trước để confirm baseline, rồi run verify của step hiện tại để chứng minh regression. Điều này biến task list thành executable spec thay vì todo list tĩnh.

Câu hỏi: bạn có thấy friction khi team member non-technical (PM, designer) cần review task list dạng này không? Mình lo việc nhúng verify command làm noise cho người không code (site: labagent .tech)

Collapse
 
yuhehe profile image
Yuhe He •

The "inspectable without trusting the agent's summary" line is the one that carries the most weight in practice, and data collection has the same disease in a nastier form. When an unattended crawler reports "22 messages today", the claim is trivially falsifiable in the good sense (re-run the crawl, diff the output) — but the agent's summary of why the number moved ("quiet day") is exactly the kind of plausible narrative that hides three different failures: source unreachable, parser died on a new page shape, genuinely quiet.

What worked for me is forcing every run to emit the disproof artifact before the claim: a per-source manifest (fetch status, record count, timestamp) written first, so "22" is inseparable from the 8 fetch lines that produced it. The summary can still lie; it just can't lie alone.

One deliberate constraint worth stealing from the two-broken-versions setup: I keep at least one source that provably posts daily as a canary. If the canary reports zero, the pipeline says FAILED even when every other source is zero — "everything is quiet" is treated as the claim that needs the most evidence, not the default. Your red-green-refactor for agents, one manifest line per run.

Collapse
 
mudassirworks profile image
Mudassir Khan •

the "done needs to mean something observable" framing is the thing most agent coding tutorials skip. the agent passes the tests. the explanation sounds good. but if the test suite was never designed to catch how that prompt breaks things, you just have confidence that moves fast.

deliberately shipping broken versions as a first class artifact is legit underrated. it turns a vibe check into a real falsification loop. the evidence folder does the job half the production post mortems wish the original team had done.

what is the hardest promise to turn into a falsifiable experiment in your stack? where does the contract get clear but the observable stay fuzzy?

Collapse
 
parsa_m profile image
Parsa Mohammadi •

a test passing is evidence for one behavior, not proof that the change is safe to ship. you also need to understand what the change affects and how those parts have behaved in production.

Collapse
 
jayeshtankariya profile image
Jayesh Tankariya •

Hi Don,

Here is my article: dev.to/jayeshtankariya/your-agent-...

Very first one. I highly appreciate your feedback

Some comments may only be visible to logged-in visitors. Sign in to view all comments.