The checkout service was already in production. A junior engineer sat down with a senior on a Tuesday afternoon and pointed at a half-specified refund path: no idempotency key in the ticket, no owner for the existing /payments contract, and a request to “just let the agent fill the gaps.” The senior did not open a prompt window. The first move was a notebook.
The pairing session below is a reconstructed walkthrough of that afternoon. It is not a claim about a named company incident, and it does not treat generated code as a design review. The useful part is the sequence: questions asked, two dead ends, and the one rule the pair kept.
The room, the ticket, the temptation
The ticket read like many 2026 agent tickets. Implement refunds. Reuse the payment client. Do not break the SPA cache. Ship today if the model is cheap enough.
Cheap generation was not the scarce resource. Shared understanding was. The junior wanted a full service sketch in one pass. The senior wanted every silent default written down before a single file changed.
They agreed on a constraint that sounds small and is not. The agent could propose code only after every guess was visible, numbered, and marked accepted or rejected by a human.
Questions the senior actually asked
The senior did not start with architecture diagrams. The questions were operational and slightly rude, which is often the useful kind.
- Who owns
/payments/refundtoday, and is the handler still the source of truth? - Is refund idempotent on
order_id, onrefund_id, or on a client token the SPA does not send yet? - Does “reuse the payment client” mean the existing Node module, or a new generated client that will drift in a week?
- What happens to the brownfield cache key when a refund changes order state without a full page reload?
- If the model invents a status enum, who is on call when production still uses the old one?
None of those questions are exotic. Each one is a place an unconstrained agent will invent a default and keep walking. The pair wrote the questions on the left side of the notebook. Empty answers went on the right as GUESS, not as code.
Dead end 1: generate first, patch later
The junior still wanted a baseline. They pasted the ticket into a coding model and asked for a refund module plus tests. The model produced a tidy RefundService, a new status enum, and a retry helper that assumed HTTP 409 meant “already refunded.”
The existing client used 409 for a different conflict. The generated tests asserted the new meaning. The pair spent twenty minutes arguing with the output instead of the ticket. That is the first dead end in plain language: generated confidence is not a substitute for a named assumption.
They deleted the branch. No patch session on top of silent defaults. The senior’s line was short. Code that encodes a guess is more expensive to unwind than a blank file.
Dead end 2: a longer system prompt
The second attempt tried to trap the model with policy. A long system prompt listed “do not invent enums,” “ask if the API is unknown,” and “prefer existing modules.” The next run still invented a RefundReason table because the ticket had the word “reason” once.
Prompt volume did not create a control loop. The model cannot mark its own guesses as guesses unless the workflow requires an artifact the human has to sign. That was the second dead end: instructions without a gate are still suggestions.
The decision they kept
The pair stopped asking the model to be careful. They asked the workflow to refuse.
The kept rule was a three-file protocol:
- A task file that states the change in human language.
- An assumption ledger that lists every gap as
open,accepted, orrejected. - A write-gate that exits non-zero if any
openor unlabeled guess remains.
Only after the gate passed would they allow a model call that could touch application code. Rejected guesses had to be replaced with a fact or with an explicit out-of-scope note. Accepted guesses became part of the commit message.
That rule is the pairing decision. Everything else in this article is machinery around it.
Artifact: the ledger and the write-gate
The ledger is deliberately boring JSON. Boring is the point. A pairing partner can read it in a minute and argue about rows instead of about a 400-line diff.
{
"task_id": "refund-path-2026-09-03",
"repo": "checkout-service",
"assumptions": [
{
"id": "A1",
"statement": "Refunds are idempotent on client_refund_token, not order_id.",
"status": "open",
"evidence": "ticket does not specify; SPA does not send a token today",
"owner": "pairing-human"
},
{
"id": "A2",
"statement": "Existing payments/client.js remains the only HTTP client.",
"status": "accepted",
"evidence": "module is imported by three live handlers",
"owner": "pairing-human"
},
{
"id": "A3",
"statement": "HTTP 409 from payments means already refunded.",
"status": "rejected",
"evidence": "client comments: 409 is duplicate capture, not refund",
"owner": "pairing-human"
}
]
}
The write-gate is a small Node script. It does not call a model. It only decides whether a model is allowed to write.
// gate.mjs — refuse code generation while guesses remain open
import { readFileSync } from "node:fs";
const ledgerPath = process.argv[2] ?? "./assumptions.json";
const ledger = JSON.parse(readFileSync(ledgerPath, "utf8"));
if (!Array.isArray(ledger.assumptions) || ledger.assumptions.length === 0) {
console.error("gate fail: ledger must list at least one assumption");
process.exit(2);
}
const blocking = [];
for (const row of ledger.assumptions) {
if (!row.id || !row.statement) {
blocking.push(`malformed row: ${JSON.stringify(row)}`);
continue;
}
if (row.status === "open" || row.status == null) {
blocking.push(`${row.id} still open: ${row.statement}`);
}
if (row.status === "rejected" && !row.evidence) {
blocking.push(`${row.id} rejected without evidence`);
}
if (row.status === "accepted" && !row.owner) {
blocking.push(`${row.id} accepted without a human owner`);
}
}
if (blocking.length) {
console.error("gate fail: agent may not write application code");
for (const line of blocking) console.error(`- ${line}`);
process.exit(1);
}
console.log(`gate ok: ${ledger.assumptions.length} assumptions resolved`);
A pairing-friendly command sequence looks like this. The model, if used at all, only drafts ledger rows first.
node gate.mjs ./assumptions.json
# exit 1 until A1 is accepted or rewritten
# after the human edits statuses:
node gate.mjs ./assumptions.json && echo "now a coding model may propose a diff"
A second, optional script can turn rejected rows into a blocklist injected into the next prompt. Keep it mechanical. Do not ask the model to remember the blocklist on its own.
// blocklist.mjs — derive prompt constraints from rejected rows
import { readFileSync } from "node:fs";
const ledger = JSON.parse(readFileSync("./assumptions.json", "utf8"));
const rejected = ledger.assumptions.filter((row) => row.status === "rejected");
const lines = rejected.map(
(row) => `Do not encode this rejected guess: ${row.statement} (${row.evidence})`
);
process.stdout.write(lines.join("\n") + "\n");
node blocklist.mjs > /tmp/rejected-guesses.txt
Where a free model and a free server actually help
The protocol above runs on a laptop. Pairing gets more honest when the gate is not optional and the model call is not tied to a personal paid key that someone is reluctant to burn on a “dumb” ledger pass.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s operator-supplied free model access and free server option are relevant here for a narrow reason: the pair can host the write-gate as a tiny HTTP check and use a free-tier coding model only after the check returns 200. No model names, quotas, hardware, or uptime claims are added beyond that availability.
A minimal gate server keeps the pairing rule outside any one editor plugin.
// server.mjs — example only; not a production service
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/gate") {
res.writeHead(404).end();
return;
}
const result = spawnSync(process.execPath, ["gate.mjs", "./assumptions.json"]);
const ok = result.status === 0;
res.writeHead(ok ? 200 : 409, { "content-type": "application/json" });
res.end(JSON.stringify({
ok,
output: String(result.stdout || result.stderr)
}));
}).listen(8787, "127.0.0.1");
node server.mjs
curl -sS -X POST http://127.0.0.1:8787/gate
The pairing loop becomes: edit the ledger, hit the gate, only then ask a free-tier model to propose a diff against files the senior already named. If the free server is the place that loop lives, the team is not waiting on a laptop process that one person forgot to run.
Readers who want to try that split — local ledger, remote cheap generation after the gate — can use MonkeyCode’s free model access and free server option as the hosting side of the same protocol. That is the only product ask in this article.
Decision table from the session
| Approach | What the pair tried | Failure mode | Keep? |
|---|---|---|---|
| One-shot generation | Ticket in, service out | Silent enums and HTTP meanings | No |
| Long system prompt | Policy text, no artifact | Model still filled gaps | No |
| Ledger without a gate | JSON notes, honor system | People skipped rows under time pressure | No |
| Ledger plus write-gate | Non-zero exit blocks code | Extra ceremony on tiny typos | Yes |
| Gate on a shared server | Same rule for both pair partners | Needs a process that stays up | Yes, if the team already wants a shared runner |
The table is the memory of the session. Future pairing can start at the last two rows instead of replaying the dead ends.
What the generated diff was allowed to contain
After A1 was rewritten — the SPA would start sending client_refund_token, and the handler would key on that token — the gate passed. The model was then allowed a narrow write: one handler, one test file, no new client, no new enum.
The senior still reviewed the diff as if a junior had typed it. The ledger did not make the code correct. It only made the guesses expensive to hide.
A sample test the pair wrote by hand, not by the model, locked the rejected HTTP meaning:
// refund.test.mjs — proposal: hand-written pin, not model output
import test from "node:test";
import assert from "node:assert/strict";
test("409 from payments is not treated as already refunded", () => {
const meaning = { 409: "duplicate_capture" };
assert.notEqual(meaning[409], "already_refunded");
});
Hand-written pins next to a ledger beat model-written tests that restate the model’s own guesses.
Limitations
The protocol does not estimate latency, token spend, or model quality. Those numbers change and are not claimed here.
It also does not replace design review, security review, or on-call ownership. A fully accepted ledger can still describe a bad system. The gate only blocks unlabeled invention.
Failure modes the pair already hit:
- Status theater: marking every row
acceptedto unblock the model. The owner field makes this visible, not impossible. - Ledger drift: code changes while rows stay stale. The commit message should list assumption IDs; skipped IDs are a review comment.
- Over-collection: twenty open rows on a three-line fix. If a change cannot touch an unknown contract, do not put the contract in the ledger.
- Unattended agents: a cron job cannot be the
ownerof anacceptedguess.
Free-tier model access and a free server do not remove those failure modes. They only lower the cost of running the gate somewhere other than a sticky note.
Who should not use this
Skip the protocol when the change is a one-line copy fix inside a file the pair already understands. Skip it when the team has no human willing to own a row. Skip it for regulated changes that need a formal design record rather than a JSON file in the repo. Skip it if the goal is fully autonomous coding; this workflow is anti-autonomy on purpose.
Teams that want an agent to invent product behavior should not dress that desire up as pairing. The senior’s kept decision was narrower: the agent may write after guesses stop being silent.
What survived the afternoon
The refund handler shipped later than the original ticket implied. The pair kept three things: the ledger format, the non-zero gate, and the rule that rejected HTTP meanings get a hand-written pin. They threw away the one-shot service, the long system prompt, and the idea that a cheaper model makes architecture optional.
That is a pairing result, not a benchmark. Another team can copy the gate in an hour, disagree with the refund token choice, and still keep the same control loop. The loop is the artifact. The rest is local fact.
Top comments (0)