DEV Community

Avery Li
Avery Li

Posted on

The Pairing Stayed Blocked Until Agent Writes Required a Red Contract

A pairing session should treat every agent file write as untrusted until a local contract test fails for the intended reason. That rule survived three documented dead ends and still governs the complete workflow described in this article. Free model access does not replace a deterministic gate that runs on the developer machine before any disk mutation. The senior engineer refused to continue pairing until that gate existed as ordinary files in source control.

The pairing constraint that stayed

The session began with a small agent loop that proposed patches against a brownfield Python service with weak tests. The junior engineer wanted the loop to edit files directly after a single model response arrived from a free endpoint. The senior engineer stopped the shared screen and required a written contract for every tool that could touch disk. The rest of the afternoon became a hunt for a gate that did not depend on paid tokens or vendor dashboards.

Cheap generation makes it easy to confuse a fluent patch with a verified change, especially when the model fills gaps the repository never stated. The pair was not debating model quality in the abstract, and the senior would not rank vendors during the session. The only question on the table was whether an unattended write could still be blocked after the model sounded confident. The answer had to live in commands that a teammate could rerun after the pairing block ended.

Checks the senior wrote on the whiteboard

The senior listed four checks before any model output could be applied as a patch in the working tree. Each check had a responsible engineer, a local command, and a failure mode that did not require reading model prose. The pair kept those checks on a sticky note beside the editor for the remainder of the pairing block. Nothing in the list mentioned model brand names, because brand names do not fail a unit test in continuous integration.

  1. The proposed path must sit inside an allowlist that excludes secrets, lockfiles, and generated vendor trees.
  2. The proposed diff must include a test file change whenever production code under src/ would change.
  3. The contract test for the claimed behavior must fail on HEAD before the agent is allowed to write.
  4. The same contract test must be runnable without network access so pairing can continue during endpoint lag.

The junior engineer argued that a careful system prompt already covered path safety and missing tests in ordinary language. The senior engineer answered by pointing at the last merge, where a fluent explanation had still written around an empty test directory. Prompt text is not an assertion, and assertions are what pairing should leave behind in the repository. The whiteboard checks stayed, and the pair started looking for a mechanical way to enforce them.

Dead end one: a longer system prompt

The first attempt stuffed the four checks into the agent preamble and asked the model to refuse unsafe writes on its own honor. The pairing then replayed a fixture in which the claimed bug lived in src/billing/prorate.py while the model edited src/billing/README.md instead. The refusal never fired, because the model treated the README as documentation of the same repair and called the write helpful. Prompt compliance is not a test result, and the senior marked the preamble approach as a dead end after that single replay.

A second prompt revision added phrases about allowlists, red tests, and never touching secrets, which only made the transcript longer. The model began quoting the preamble back to the pair while still emitting a write tool call against a path outside the allowlist. The junior engineer watched the quoted policy and the violating call sit in the same JSON payload without conflict. After that payload, the pair stopped editing prompts and started editing Python.

Dead end two: audit logs after the write

The second attempt wrapped the apply step in a logger that recorded path, hash, and timestamp once the file already sat on disk. The log looked responsible during the demo, until the senior asked what command would unapply a write that had compiled and been left in the tree. There was no inverse besides git checkout, and git checkout is not a contract; it is an apology. Logging after mutation teaches the next session how the damage happened, not how to keep the damage off disk.

The pair also noticed that free endpoints can stall, retry, or emit duplicate tool calls when the client library hides those retries. A post-write log then recorded two mutations for one intended patch, and the second mutation landed on a file the allowlist would have rejected. The senior engineer called that pattern a silent amplifier and refused to keep an audit trail as the primary control. The log could stay as diagnostics, but it could not remain the gate.

Dead end three: trusting pretty JSON from a free tier

The third attempt validated the tool-call payload against a JSON Schema and treated a valid document as permission to write. Schema validity only proves that keys exist and types match, which is a low bar for a patch that will change production behavior. The fixture payload was perfectly valid and still described a refactor the repository did not need, aimed at a function the failing product test never executed. The senior engineer called schema validation necessary and still insufficient, then asked for a test that failed on current HEAD for the claimed behavior.

Pretty JSON is a transportation format, not evidence that the agent understood the bug. Free-tier models are useful for drafting a patch proposal, yet they do not become more truthful when the JSON happens to parse. The pair needed a gate whose red or green state came from the same test runner the service already used. That requirement became the decision the pairing kept after the three dead ends.

The decision the pairing kept

The pair kept a single rule: the agent may not write until a local contract test is red for the behavior the patch claims to fix. After the write, the same test must be able to turn green without any network call, or the patch is reverted before the session ends. Generation can happen on a free model endpoint or a free shared server, because generation is not the source of truth. The source of truth is the contract file that a teammate can run with pytest after the call ends.

That decision is narrower than a full agent platform and wider than a lint rule. It does not pretend to stop every bad refactor, and it does not grade model vendors. It only blocks the most expensive pairing failure, which is an unattended disk mutation justified by fluent language. The rest of this article records the files the pair left in the repository so the next session can start from the same gate.

A reproducible contract gate

The artifact below is a small Python module plus a pytest file. It is a pairing tool, not a production agent runtime, and it should be copied into a throwaway branch first. Proposed writes arrive as JSON on stdin or from a fixture file, which keeps the gate testable without live model traffic. Live traffic is optional and belongs behind an explicit flag after the contract is already red.

Step 1: Freeze the write proposal as data

The pair stopped passing free-form chat into the apply function and required a proposal document with a closed set of keys. The document names the production path, the test path, the claimed behavior identifier, and a unified diff body. Missing keys fail closed, and extra keys fail closed, so a chatty model cannot smuggle a second write through an ignored field. The schema lives beside the gate so a reviewer can read it without opening a vendor console.

# agent_contract.py
from __future__ import annotations

import json
from pathlib import Path
from typing import Any

ALLOWED_ROOTS = ("src/", "tests/")
FORBIDDEN_PARTS = (".env", "secrets", "node_modules", "vendor/", "dist/")
REQUIRED_KEYS = ("behavior_id", "src_path", "test_path", "diff")


class ContractError(ValueError):
    pass


def load_proposal(raw: str) -> dict[str, Any]:
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise ContractError(f"proposal is not JSON: {exc}") from exc
    if not isinstance(data, dict):
        raise ContractError("proposal must be a JSON object")
    extra = set(data) - set(REQUIRED_KEYS)
    missing = set(REQUIRED_KEYS) - set(data)
    if extra or missing:
        raise ContractError(f"key mismatch extra={sorted(extra)} missing={sorted(missing)}")
    return data
Enter fullscreen mode Exit fullscreen mode

Step 2: Reject paths before diffs are interesting

Path policy runs before the diff parser, because a forbidden path should never reach a pretty-print step during pairing. The senior engineer wanted that failure to be boring, fast, and visible in the pytest summary. Relative paths are resolved against the repository root and then compared as POSIX strings to avoid host-specific absolute prefixes. Symlink escapes are out of scope for this gate and belong in a later hardening pass if the agent ever runs unattended.

def assert_safe_path(repo: Path, rel: str, *, must_be_test: bool = False) -> Path:
    if rel.startswith("/") or ".." in Path(rel).parts:
        raise ContractError(f"unsafe path: {rel}")
    if must_be_test and not rel.startswith("tests/"):
        raise ContractError(f"test path required, got {rel}")
    if not rel.startswith(ALLOWED_ROOTS):
        raise ContractError(f"path outside allowlist: {rel}")
    if any(part in rel for part in FORBIDDEN_PARTS):
        raise ContractError(f"forbidden path fragment: {rel}")
    resolved = (repo / rel).resolve()
    if repo.resolve() not in resolved.parents and resolved != repo.resolve():
        raise ContractError(f"path escaped repo: {rel}")
    return resolved
Enter fullscreen mode Exit fullscreen mode

Step 3: Demand a red contract on current HEAD

The pairing rule is TDD for agent writes. The claimed behavior_id must map to a pytest node that fails before the patch is applied. The gate shells out to pytest with network disabled at the process level when the platform supports it, and it treats a green result as a reason to refuse the write. A missing test file is also a refusal, which closed the README-only repair from the first dead end.

import os
import subprocess
import sys

BEHAVIOR_TO_NODE = {
    "prorate_partial_month": "tests/test_prorate.py::test_partial_month_rounds_down",
}


def contract_is_red(repo: Path, behavior_id: str) -> None:
    node = BEHAVIOR_TO_NODE.get(behavior_id)
    if not node:
        raise ContractError(f"unknown behavior_id: {behavior_id}")
    env = os.environ.copy()
    env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1"
    proc = subprocess.run(
        [sys.executable, "-m", "pytest", node, "-q"],
        cwd=repo,
        env=env,
        capture_output=True,
        text=True,
        check=False,
    )
    if proc.returncode == 0:
        raise ContractError(
            f"contract already green on HEAD for {behavior_id}; refusing write"
        )
    if proc.returncode not in (1,):
        raise ContractError(
            f"pytest could not run {node}: rc={proc.returncode}\n{proc.stderr}"
        )
Enter fullscreen mode Exit fullscreen mode

Step 4: Apply only after the red check, then re-run

The apply function is intentionally dull. It writes the unified diff through patch --dry-run first, then through patch, then reruns the same pytest node. If the node stays red, the function runs git checkout -- on the touched paths and raises, which is the inverse the audit-log dead end never had. The pairing session treated that revert as part of the happy path for a bad proposal, not as an exceptional panic.

import subprocess
from pathlib import Path


def apply_if_contract_red(repo: Path, proposal: dict) -> None:
    src = assert_safe_path(repo, proposal["src_path"])
    test = assert_safe_path(repo, proposal["test_path"], must_be_test=True)
    contract_is_red(repo, proposal["behavior_id"])
    diff_path = repo / ".pairing" / "proposal.diff"
    diff_path.parent.mkdir(parents=True, exist_ok=True)
    diff_path.write_text(proposal["diff"], encoding="utf-8")
    dry = subprocess.run(
        ["patch", "-p1", "--dry-run", "-i", str(diff_path)],
        cwd=repo,
        capture_output=True,
        text=True,
        check=False,
    )
    if dry.returncode != 0:
        raise ContractError(f"diff does not apply cleanly:\n{dry.stderr}")
    subprocess.run(
        ["patch", "-p1", "-i", str(diff_path)],
        cwd=repo,
        check=True,
    )
    try:
        # After apply, the same node must turn green without network.
        env = dict(**{**__import__("os").environ, "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1"})
        proc = subprocess.run(
            [sys.executable, "-m", "pytest", BEHAVIOR_TO_NODE[proposal["behavior_id"]], "-q"],
            cwd=repo,
            env=env,
            capture_output=True,
            text=True,
            check=False,
        )
        if proc.returncode != 0:
            raise ContractError(f"patch left contract red:\n{proc.stdout}\n{proc.stderr}")
    except ContractError:
        subprocess.run(["git", "checkout", "--", str(src), str(test)], cwd=repo, check=False)
        raise
Enter fullscreen mode Exit fullscreen mode

Step 5: Keep a fixture the model never sees during the unit run

The pair stored a known-bad proposal and a known-good proposal under tests/fixtures/agent/. Pytest reads those files and never calls a model, which keeps CI honest when the free endpoint is slow or absent. The known-bad fixture is the README write from the first dead end, rewritten as JSON so it cannot hide inside a chat transcript. The known-good fixture is a tiny prorate patch that exists only to prove the apply path can turn a red node green.

# tests/test_agent_contract.py
from pathlib import Path

import pytest

from agent_contract import ContractError, apply_if_contract_red, load_proposal

REPO = Path(__file__).resolve().parents[1]


def test_readme_write_is_rejected():
    raw = (REPO / "tests/fixtures/agent/bad_readme_write.json").read_text()
    proposal = load_proposal(raw)
    with pytest.raises(ContractError, match="test path required"):
        apply_if_contract_red(REPO, proposal)


def test_unknown_behavior_is_rejected():
    raw = '{"behavior_id":"nonesuch","src_path":"src/billing/prorate.py","test_path":"tests/test_prorate.py","diff":""}'
    proposal = load_proposal(raw)
    with pytest.raises(ContractError, match="unknown behavior_id"):
        apply_if_contract_red(REPO, proposal)
Enter fullscreen mode Exit fullscreen mode
{
  "behavior_id": "prorate_partial_month",
  "src_path": "src/billing/README.md",
  "test_path": "src/billing/README.md",
  "diff": "--- a/src/billing/README.md\n+++ b/src/billing/README.md\n@@ -1 +1,2 @@\n # billing\n+agent was here\n"
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Optional generation behind an explicit flag

Only after the fixtures passed did the pair allow a live model to draft a proposal JSON file. The command below writes to .pairing/proposal.json and stops; it does not apply. Apply remains a second command that the senior engineer ran by hand while watching the pytest output. That split is the whole pairing lesson, and it does not depend on which vendor produced the JSON.

mkdir -p .pairing tests/fixtures/agent
python -m pytest tests/test_agent_contract.py -q
python scripts/draft_proposal.py --behavior prorate_partial_month --out .pairing/proposal.json
python scripts/apply_proposal.py --in .pairing/proposal.json
Enter fullscreen mode Exit fullscreen mode

The draft script is deliberately not shown as a clever agent framework. It should send the behavior identifier, the failing test output, and the allowed paths, then expect a JSON object that load_proposal will accept. If the model returns markdown fences or an extra files array, the gate fails closed and the pair returns to the fixture. The pairing stayed productive because failure looked like pytest, not like an argument with a chatbot.

Where free model access belongs in this loop

Disclosure: This article was prepared as part of MonkeyCode's product outreach. The pairing needed a generation backend that would not halt the session while someone hunted for a billed key. MonkeyCode is an open-source coding assistant with free model access and a free server option, which the pair treated as an optional draft endpoint behind the flag in step six. The product does not replace the contract files, and this article does not claim model names, quotas, hardware profiles, or durability of any free tier.

The useful split is local truth versus remote draft. Local truth is pytest, patch --dry-run, the path allowlist, and the revert on a still-red contract. Remote draft is any endpoint that can emit the four-key JSON document, including a free server the team can share during pairing without installing a GPU under the desk. If the endpoint is down, the fixtures still teach the next engineer why README writes are rejected. That property is why the senior engineer allowed a free backend at all.

Readers who want to exercise the same split can point the draft script at MonkeyCode’s free model access or free server option and keep the pytest gate unchanged. The pairing decision does not require that choice, and a different draft endpoint should produce the same fixture failures. The article mentions the product here because it actually sat in the optional generation slot, not because a gate needs a brand name. Remove the product mention and the red-contract rule still stands.

Limitations the senior refused to hide

This workflow does not prove that the patch is production-ready, only that one named pytest node moved from red to green. It will miss regressions in untested neighbors, and brownfield services with weak tests will receive weak protection. Unified diffs that touch many files can still satisfy a single node while rewriting unrelated modules the allowlist permits. The pair accepted that gap rather than pretending a schema or a prompt could close it.

The gate also assumes git and patch exist, and it assumes the repository is clean enough that git checkout -- is a safe inverse. Binary files, submodule pointers, and generated protocol buffers are outside the fixture set and should stay outside the allowlist. Concurrent pairing on the same branch can race the red-then-green sequence, so the workflow belongs on a throwaway branch with one writer. None of those limits are solved by switching model vendors.

Free model access introduces ordinary remote-system failures such as truncated JSON, stalled streams, and retries that duplicate a tool call. The gate is designed to fail closed on those shapes, which can frustrate a junior engineer who wanted a one-shot repair. That frustration is cheaper than an unattended write, and it is the point of the pairing rule. Teams that need unattended overnight loops should not use this approach without additional isolation.

Who should not use this approach

Teams without any failing test for the behavior under discussion should not install this gate and hope the agent will invent coverage. The contract map will refuse unknown behavior_id values, and that refusal is correct. Teams that cannot run pytest offline, or that patch production from a laptop against live customer data, should also stay away. The workflow is for pairing on an ordinary branch with a revert path, not for operating a production agent.

Engineers who want a fully autonomous refactor across a monorepo will find the four-key proposal format too tight. That tightness is the decision the pairing kept, and loosening it would return the session to the three dead ends. The senior engineer preferred a small green node over a large unexplained diff, even when the larger diff looked cheaper to generate. Cheap generation is not cheap review, and review still needs a red test that a human can name.

What the next pairing should copy

The next session should copy the proposal schema, the path allowlist, the red-before-write check, and the revert on a still-red node. It should not copy prompt folklore from the first dead end, post-write log theater from the second, or schema-only optimism from the third. Model endpoints can be swapped, including a free open-source assistant used only to draft JSON, without changing those four files. The pairing ends when the contract is green and the inverse still exists in git checkout --.

Top comments (0)