For Business LeadersFor Technology ExecutivesHow we workWorkAboutBlog
Let's Talk
Tuesday, 3 November, 2026
Janne Haarni
Technology

TL;DR When agents write both the code and the tests, the tests end up agreeing with whatever was built. Our harness makes a human-approved Gherkin feature the contract: a separate agent writes the steps blind from the spec, every scenario must fail for the right reason before any code exists, and the steps are frozen while the code is written. Godog runs inside go test, so approved features are just part of the test suite and CI. Hooks and lint enforce the loop, rather than instructions the agent may ignore.

Behaviour first, code last: a harness for agent-written Go

Post cover image

Creating code has become cheap. Deciding exactly what to build has not, and that is where agents can go wrong. An agent that misread the intent produces code that compiles, passes its own tests and looks plausible in review.

We have been building a harness for our Go services to make that intent explicit, reviewable and enforceable. This post is about the harness and the workflow around it: how a Gherkin feature becomes the contract for a change, why Go and godog turned out to be a good fit, and which guardrails actually hold when an agent writes most of the code.

Plans are just textLink to Plans are just text

The usual way to steer a coding agent is a Markdown plan. Plans are useful, but they mix requirements, implementation ideas and task lists into one document. Reviewers skim them and approve them. And because a plan never runs, it starts drifting from the code the moment work begins.

Spec-driven development tools fix much of the first half. GitHub’s Spec Kit, Kiro, OpenSpec and BMAD separate requirements from design and tasks, and put a review gate between the phases. Kiro writes requirements in a structured notation (EARS) that reads a lot like Given/When/Then. The phase gates in our workflow are the same idea. But in most of these workflows the spec is still a document the agent reads rather than something that runs, and whether the code matches it is left to review.

Another problem is that in an agentic workflow the agent that writes the code also writes the tests. An agent that has just built something (and a human as well!) will naturally write tests that describe what it built. The tests pass, the coverage looks fine, and the original intent has been silently replaced by the implementation.

So we wanted two things. First, a review artifact that keeps the human focused on behaviour, on who calls, with what, and what they must see, so that approving it is a real decision rather than a skim. Second, a way to break the loop where the agent grades its own homework.

Gherkin as the contractLink to Gherkin as the contract

Behaviour-driven development gives us the first part. As Vitaliy Potapov puts it, “a BDD scenario is both a conversation and an automated test.” The planning effort turns into a regression test instead of a document that ages. Our pilot for the harness is one of our Go services, an MCP server that sits in front of tools and backends with several authorization gates.

A feature in our harness looks roughly like this:

@draft @wip
Feature: Policy-based authorization of tool calls

  # Decisions:
  # - A denied call never reaches the backend.
  # - The caller sees the policy's denial reason.

  Scenario: Denied call never reaches the backend
    Given the catalog has a tool:
      """
      name: restart_node
      url: {{backend}}/restart
      """
    And the policy engine denies "restart_node" with reason "outside change window"
    And I am signed in as "alice" of team "sre"
    When I call "restart_node" with {"node": "ABA5A209BF2C"}
    Then the call fails with "outside change window"
    And the backend received no requests

Scenarios speak the platform’s domain language (catalog, tool, team, gate, backend). Each scenario covers one behaviour, and tool definitions go inline so every scenario is self-contained.

The second part, breaking the self-grading loop, comes from three checks that carry the whole method:

  1. The spec must stand on its own. Step definitions are written by a separate subagent that receives nothing but the path to the feature file. It never saw the design conversation. Anything it has to guess is a defect in the spec, reported back to the human rather than quietly reinterpreted.
  2. Every Then must be falsifiable. It has to fail against a server that ignores the feature. A Then that would pass on an empty implementation proves nothing.
  3. Red means red for the right reason. Before any implementation, every scenario must fail, and only an assertion failure in a Then step counts. Undefined steps, panics, compile errors and setup errors are not evidence that the behaviour is missing.

Together these make the tests something the implementing agent cannot negotiate with. They are written blind from an approved spec, proven to fail before code exists, and frozen while the code is written.

Why godogLink to Why godog

The BDD part of the harness runs on godog, the Cucumber implementation for Go, and the Go toolchain does a surprising amount of the heavy lifting.

Features are just testsLink to Features are just tests

The most important property is boring: godog runs inside go test. There is no separate runner, no separate CI job and no second toolchain to keep in sync. Approved features are part of make test, which means they are part of the commit gate and CI from the moment they are promoted. The runner is a single test function (simplified):

func TestFeatures(t *testing.T) {
    suite := godog.TestSuite{
        ScenarioInitializer: initializeScenario,
        Options: &godog.Options{
            Format:   env("BDD_FORMAT", "pretty"),
            Paths:    []string{env("BDD_PATHS", "../../features")},
            Tags:     env("BDD_TAGS", "~@wip && ~@draft"),
            Strict:   env("BDD_STRICT", "true") == "true",
            TestingT: t,
        },
    }
    if suite.Run() != 0 {
        t.Fatal("feature run failed")
    }
}

Four environment variables make the same runner serve every purpose. Tag expressions keep drafts and work-in-progress out of the regression suite by default. Strict mode, on by default, makes undefined or pending steps fail rather than pass silently, which is exactly what the red-phase rule needs. Turning strict mode off for @draft features gives a cheap syntax check that lists undefined steps as ready-made snippets. A cucumber JSON formatter writes a machine-readable report for agents to read instead of scraping terminal output.

One world per scenarioLink to One world per scenario

Every scenario runs against a fresh, in-process server on a random port, with its own temporary tools directory, stub services and MCP client session. All of that lives in a world value that godog carries through the step context. Nothing is shared between scenarios, so order does not matter and a failing scenario cannot poison the next one.

Go makes this cheap. Spinning up a real HTTP server on port 0 inside the test process takes milliseconds, stub services are a few lines of net/http, and godog’s Before and After hooks give a natural place to build and tear down the world:

func initializeScenario(sc *godog.ScenarioContext) {
    sc.Before(func(ctx context.Context, _ *godog.Scenario) (context.Context, error) {
        return withWorld(ctx, newWorld()), nil
    })
    sc.After(func(ctx context.Context, _ *godog.Scenario, _ error) (context.Context, error) {
        worldFrom(ctx).Close() // session, idle connections, server, stubs, temp dir
        return ctx, nil
    })
    registerCatalogSteps(sc)
    registerIdentitySteps(sc)
    // ... one register function per domain
}

The server starts lazily, on the first step that needs the client, so Given steps can still adjust configuration and write tool definitions. Before connecting, the harness checks the readiness endpoint, so an invalid tool definition shows up as a setup error rather than a misleading “unknown tool” three steps later.

Each external dependency becomes a stub HTTP service on the world that records what it receives and answers from a scriptable function. The policy engine stub is the template: it records method, path and body, answers through a decide(input) function, and supports a delay that ends when the request is cancelled. That last part is pure Go idiom, select on the request context, and it lets scenarios test timeouts without sleeping.

// Simplified
func (s *stubPolicyEngine) handle(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)

	s.mu.Lock()
	s.calls = append(s.calls, recordedRequest{r.Method, r.URL.Path, body})
	decide, delay := s.decide, s.delay
	s.mu.Unlock()

	if delay > 0 {
		select {
		case <-time.After(delay):
		case <-r.Context().Done():
			return // the server gave up waiting; nothing left to answer
		}
	}

	var req struct {
		Input map[string]any `json:"input"`
	}
	_ = json.Unmarshal(body, &req)
	allow, reason := decide(req.Input)
	_ = json.NewEncoder(w).Encode(map[string]any{
		"result": map[string]any{"allow": allow, "reason": reason},
	})
}

Step definitions live in one file per domain and act only through the public surface: MCP, HTTP endpoints, stubs and the server’s configuration struct. They never reach into internal packages to arrange or inspect state. That keeps a passing feature meaningful after a refactor.

The workflow: three human approvalsLink to The workflow: three human approvals

A feature passes three human approvals: the spec, the red steps and the final green state. Agents do everything between them, and once the spec is approved it is read-only to them.

The Workflow

In Claude Code this is two slash commands. /bdd-spec <intent> drafts a feature tagged @draft @wip, including a # Decisions: block that records what was decided along the way. The human reviews and iterates, then approves by deleting @draft. Only a human may do that.

/bdd-implement then runs phase 1. It hands the feature path, and nothing else, to the bdd-steps agent. Adding context would defeat the check that the spec stands on its own. The main agent then verifies independently: it reads the steps, runs the feature, confirms every scenario fails on a Then assertion, and confirms the rest of make test is still green. Then it stops and reports the steps, the red results and any spec defects.

After the human approves the red steps, phase 2 implements the change. The agent may touch step definitions only at explicit TODO(impl) wiring points, for example where the server lacks a configuration field the feature needs. A step that turns out to be wrong is raised to the human, not fixed quietly. The @wip tag keeps the feature out of make test while it is red; removing it promotes the feature into the regression suite.

This is where accountability lives. Each approval is a small, recorded human decision about behaviour, visible in the history as a tag change or a commit. The agent does the work, but it cannot decide on its own what “done” means.

Guardrails are mechanisms, not instructionsLink to Guardrails are mechanisms, not instructions

The most important design principle in the harness is that the loop is held by mechanisms rather than by instructions in Markdown. Skills and agent prompts explain the method, but hooks in Claude Code and steps in CI block the wrong move outright. One hook makes approved features read-only and reserves removing @draft for humans. Another returns lint findings within a second of every Go edit, and refuses a commit, or the end of the agent’s turn, until lint, tests and the coverage check pass. The gates diff against the branch’s fork point, so changes made through a shell command are caught too, and every hook blocks rather than failing open if it crashes.

Lint carries structure as well as style. A pinned golangci-lint with depguard import allow-lists turns the package architecture into build failures, so a boundary violation reaches the agent as a failing check within a second, not as a review comment it may never read.

The refactor guard deserves a special mention because it turns “behaviour-preserving” from an intention into a rule. On a refactor/* branch the existing features and tests are the specification, so the agent cannot change them. New test files are allowed. If a refactor needs an existing test changed, it is changing behaviour or a public API, and the agent has to stop and say so.

BDD is one layer of fiveLink to BDD is one layer of five

BDD is not the whole test strategy, and pretending it is would push the wrong things into Gherkin. Unit, component, behaviour, contract and architecture tests each have a lint-enforced definition of what they may touch, and make test merges their coverage into one profile. A coverage gate then checks the statements the change touched rather than a global percentage, and in the authentication and authorization packages every changed statement must be covered. That strict rule found three real authentication bugs the first time it ran, none of which a happy-path feature would have caught.

Where BDD stops: design proposalsLink to Where BDD stops: design proposals

Package boundaries, data contracts and cross-cutting concerns do not fit in a scenario, so they get a design proposal instead. It passes the same kind of gates as a feature: numbered decisions, a cold review by a separate agent given only the path, and human acceptance. Every decision must name what verifies it, whether a feature, a test, a lint rule or a contract file, so a design compiles down to things that can fail. On the first proposal, the cold review caught a fail-open before any code moved.

ClosingLink to Closing

The point of all this is not BDD for its own sake. It is accountability in a workflow where most of the code is written by something that cannot be held accountable. The human decides what the system must do, in a form that is short enough to actually review and precise enough to execute. The agent does the work, but it does it against a contract it did not write and cannot edit, and every step from intent to merge leaves a trace of who decided what.

Go and godog made this practical: features are just tests, isolation is cheap, the race detector and shuffled order check our claims, and the linter turns architecture into fast feedback. The rest is a handful of small, unglamorous hooks, each of which blocks exactly one wrong move.

Janne Haarni
Tuesday, 3 November, 2026

Related posts

From Agentic AI to Operational Intelligence

by Mike Jackson
Cover Image for From Agentic AI to Operational Intelligence

What SaaStr's AI Agent Deployment Actually Teaches Us

by Tapio Nissilä
Cover Image for What SaaStr's AI Agent Deployment Actually Teaches Us

The AI Tools Work. The Hard Part Is Everything Around Them.

by Tapio Nissilä
Cover Image for The AI Tools Work. The Hard Part Is Everything Around Them.
Read all stories

AI-First Engineering Growth Partner. We help technology leaders turn AI ambition into business results.

Github • LinkedIn • YouTube

Menu

  • Home
  • For Business Leaders
  • For Technology Executives
  • How we work
  • Work
  • About
  • Blog

Company

Business-id: 3374640-7
E-invoice: 003733746407
Operator: 003723327487 / Apix
metosin@skannaus.apix.fi

first.last@metosin.fi

Open Source

We maintain popular Clojure libraries used by thousands of developers:

Malli (Data validation) Reitit (Routing) Jsonista (JSON processing) Muuntaja (Content negotiation)

View on GitHub

Tampere • Helsinki • Jyväskylä • Oulu