DEV Community

Avery Li
Avery Li

Posted on

From Pairing Call to CI Gate: Encoding One Senior Objection as a Test

A pairing session pays for itself only when one objection from it can be executed later, without the senior in the room. Everything else — the tangent about naming, the two drafts nobody kept, the coffee — is atmosphere. This article turns a single spoken objection into a checked gate: a JSON decision file, a ~90-line standard-library Python script, and one CI step. The gate runs offline on purpose, so the free server that made the session cheap never becomes load-bearing for the result.

The session, compressed

The patch under review was a generated worker that retries a flaky upstream call. A reviewer with ten years on the service, referred to here as R., and the author read the draft together for roughly forty minutes. Candidate drafts came from MonkeyCode's free model access, which is the reason a second and third version existed at all; a free server option let both people run the draft once and watch it fail on the same input. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Three things were discussed and one survived. The naming of attempt versus try_index was dropped because both names are fine. A proposed metrics counter was dropped because nobody could say who would read it. What survived was a single sentence from R.: the retry budget must be a module constant, and no test may prove the worker correct while depending on the free server being reachable. That sentence is the whole output of the session, and it is worth more than the diff.

Step 1 — Write the decision down before the call ends

The rule that keeps this honest is one claim per decision, and every claim must name a file pattern or a path. A claim that cannot be expressed as a pattern is a preference, and preferences belong in a comment, not a gate. The file below is the complete input to the checker in step 2.

{
  "session": "worker-retry-2026-10-15",
  "owner": "worker-team",
  "decisions": [
    {
      "id": "D1",
      "claim": "The retry budget is a module constant; no code path reads it from the environment.",
      "forbid_regex": [
        { "glob": "worker/**/*.py", "pattern": "RETRY_BUDGET\\s*=\\s*int\\(os\\.environ" }
      ],
      "require_changed": ["tests/test_retry_budget.py"],
      "expires": "2027-01-15"
    },
    {
      "id": "D2",
      "claim": "The retry test never addresses a network host, so it passes with the free server unreachable.",
      "forbid_regex": [
        { "glob": "tests/**/*.py", "pattern": "https?://" }
      ],
      "require_changed": ["tests/test_retry_budget.py"],
      "expires": "2027-01-15"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Two fields carry more weight than they look. require_changed forces the patch to bring test evidence in the same commit, which is the mechanical version of R.'s objection. expires exists so the gate dies if nobody re-argues it; an unexpiring rule written during one call becomes folklore within two quarters.

Step 2 — A checker that reads the diff, not opinions

The script below uses only the standard library and git, so it runs anywhere the repository already runs. It compares the decision file against git diff base...HEAD, collects added lines per path, and exits non-zero on the first contradiction.

#!/usr/bin/env python3
"""check_decisions.py — fail when a patch contradicts a decision made in pairing."""
from __future__ import annotations

import argparse
import datetime
import fnmatch
import json
import re
import subprocess
import sys
from pathlib import Path


def git(*args: str) -> str:
    return subprocess.run(("git", *args), check=True, text=True,
                          capture_output=True).stdout


def changed_files(base: str) -> list[str]:
    out = git("diff", "--name-only", f"{base}...HEAD")
    return [line.strip() for line in out.splitlines() if line.strip()]


def added_lines(base: str) -> dict[str, list[str]]:
    """Map path -> added lines, parsed from a zero-context diff."""
    diff = git("diff", "-U0", "--no-color", f"{base}...HEAD")
    result: dict[str, list[str]] = {}
    path = None
    for line in diff.splitlines():
        if line.startswith("+++ b/"):
            path = line[6:]
            result.setdefault(path, [])
        elif path and line.startswith("+") and not line.startswith("+++"):
            result[path].append(line[1:])
    return result


def check(decision: dict, files: list[str], added: dict[str, list[str]],
          today: str) -> list[str]:
    problems: list[str] = []
    ident = decision["id"]

    for glob in decision.get("forbid_paths", []):
        problems += [f"{ident}: forbidden path changed: {f}"
                     for f in files if fnmatch.fnmatch(f, glob)]

    for rule in decision.get("forbid_regex", []):
        rx = re.compile(rule["pattern"])
        for path, lines in added.items():
            if not fnmatch.fnmatch(path, rule["glob"]):
                continue
            for line in lines:
                if rx.search(line):
                    problems.append(
                        f"{ident}: {path} adds `{line.strip()}`")

    for glob in decision.get("require_changed", []):
        if not any(fnmatch.fnmatch(f, glob) for f in files):
            problems.append(f"{ident}: no changed file matches {glob}")

    if decision.get("expires", "9999-12-31") < today:
        problems.append(
            f"{ident}: expired on {decision['expires']}; re-decide it")

    return problems


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--config", default="decisions.json")
    parser.add_argument("--base", default="origin/main")
    parser.add_argument("--today", default=None,
                        help="fixed date, for reproducible runs")
    args = parser.parse_args()

    config = json.loads(Path(args.config).read_text())
    today = args.today or datetime.date.today().isoformat()
    files = changed_files(args.base)
    added = added_lines(args.base)

    problems: list[str] = []
    for decision in config["decisions"]:
        problems += check(decision, files, added, today)

    if problems:
        print("Pairing decisions contradicted:", file=sys.stderr)
        for problem in problems:
            print(" -", problem, file=sys.stderr)
        return 1

    print(f"{len(config['decisions'])} decisions hold")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

The --today flag matters more than it appears. Expiry logic that reads the wall clock is untestable, and an untestable gate becomes the first thing someone disables on a Friday.

Step 3 — Reproduce both outcomes in a throwaway repository

The sequence below builds a two-commit fixture, then runs the checker against a passing patch and a violating one. Expected output is included, so the reproduction is checkable without reading the script first.

mkdir -p /tmp/pairing-gate && cd /tmp/pairing-gate
git init -q -b main
mkdir -p worker tests
printf 'RETRY_BUDGET = 3\n' > worker/retry.py
printf 'def test_budget_is_constant():\n    assert True\n' > tests/test_retry_budget.py
git add -A && git commit -qm "base"
Enter fullscreen mode Exit fullscreen mode

Case A — a patch that keeps the decision:

printf 'RETRY_JITTER = True\n' >> worker/retry.py
printf '\ndef test_jitter_is_not_configurable():\n    assert True\n' \
  >> tests/test_retry_budget.py
git commit -qam "add jitter, keep budget constant"
python3 check_decisions.py --config decisions.json --base main~1 --today 2026-10-17
# 2 decisions hold
Enter fullscreen mode Exit fullscreen mode

Case B — a patch that quietly makes the budget configurable and points a test at a host:

printf 'import os\nRETRY_BUDGET = int(os.environ["RETRY_BUDGET"])\n' > worker/retry.py
printf 'import requests\n\ndef test_reachable():\n    requests.get("http://example.invalid/health")\n' \
  >> tests/test_retry_budget.py
git commit -qam "make budget configurable"
python3 check_decisions.py --config decisions.json --base main~1 --today 2026-10-17
# Pairing decisions contradicted:
#  - D1: worker/retry.py adds `RETRY_BUDGET = int(os.environ["RETRY_BUDGET"])`
#  - D2: tests/test_retry_budget.py adds `    requests.get("http://example.invalid/health")`
# exit code 1
Enter fullscreen mode Exit fullscreen mode

Case B is the entire value of the hour. The violation is caught by a rule written while R. was still on the call, six weeks before anyone re-reads the diff with fresh eyes.

Step 4 — Wire it into pull requests

One job, one step, and a full-history checkout so the merge base resolves. Fetching depth matters here; a shallow clone silently turns base...HEAD into something the checker cannot reason about.

name: pairing-decisions
on: pull_request
jobs:
  decisions:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Check decisions
        run: >
          python3 check_decisions.py
          --config decisions.json
          --base "origin/${{ github.base_ref }}"
Enter fullscreen mode Exit fullscreen mode

Keep the step non-blocking for the first week and read what fires. In the fixture above only two rules exist; a real repository will show which patterns are too broad before they annoy anyone.

Step 5 — Retire decisions on purpose

A gate that nobody deletes is a gate that eventually rejects correct code. The expiry field forces a monthly five-minute review with three possible outcomes: renew with a new date, narrow the pattern, or delete the rule because the code shape now enforces it. In this session, D1 was later replaced by a type-level constant, and the regex was deleted rather than extended; D2 stayed, because tests still reach for hosts when nobody is watching.

Where the free model and the free server actually sat

MonkeyCode appeared twice in the session, both times in the draft phase. Free model access produced a second version of the retry loop after R. rejected the first, which saved the usual week of asynchronous back-and-forth. The free server option ran that version once so both people watched the same failure instead of describing it. Nothing from either was used to prove correctness, and the gate deliberately runs offline for exactly that reason.

Token allowances, model lists and server terms change without notice, so this article quotes none of them. Check the project's own documentation for current numbers before planning work around them, and treat any figure you read in a blog post as stale until verified.

Limitations and who should skip this

A solo prototype with no CI does not need a decision file; the same knowledge lives in the author's head for the two weeks the code exists. Teams whose base branch accumulates dozens of unrelated commits will find require_changed noisy until they switch to the merge base explicitly. Regex gates check shape, not meaning: a determined patch can move the violation one file over and pass, so the gate raises the cost of an accident rather than preventing a decision. Finally, the checker says nothing about whether the generated worker is correct — it only says whether the patch contradicts what two people agreed to.

Who should not use it: teams that cannot commit to a monthly review, repositories where the reviewer changes every patch, and anyone tempted to add a rule after every disagreement. Two rules that survive review are worth more than twenty that nobody reads. If the session produced no objection a machine can check, the honest output is a comment on the pull request and no gate at all.

For anyone replaying this session, free model access gets a second draft in front of a reviewer within the hour, and the free server option gets both people looking at the same failure — check the project's current terms first, then spend the saved time on the objection that will outlive the call.

Top comments (0)