DEV Community

Quinn Sun
Quinn Sun

Posted on

The Senior Parked Staging After the Model Invented /v2/purge

The pairing room had one shared terminal and a blue sticky note on the bezel: no staging writes. The junior had already pasted a coding-assistant transcript. It claimed the generated client would exercise billing the way production does.

The senior did not open the client. The senior opened the transcript and counted HTTP verbs. Four GET lines. One DELETE. Nobody had typed DELETE into the prompt.

That was the session. Not a model bake-off. A decision about who is allowed to dial a network.

The note below is a reconstructed pairing log, not a named-tenant postmortem. Paths are fixtures. No production host is quoted as live.

The ticket was smaller than the transcript

The team needed a replayable check that a billing client still spoke a known API. The check had to fail in CI when a path vanished. It did not need to generate load. It did not need to discover endpoints.

The junior treated the assistant as a live caller. The senior treated it as a text generator that sometimes emits HTTP.

Those are different jobs. Mixing them is how a pairing session burns a staging tenant.

What the senior asked before anyone ran curl

The senior spoke in a flat list. The junior typed answers under each line.

  1. Which host the cassette is allowed to touch.
  2. Which verbs exist in the production client today.
  3. Whether any call mutates, even behind an idempotency key.
  4. What the assistant may invent: bodies, paths, or neither.
  5. Where the dry-run runs, and who owns the logs.

The junior had answers for the first two. The rest was hand-waving. The session stopped being about trying a hosted coding model. It became about a gate that fails closed.

Dead end one: the careful-model promise

The junior added a system line: never send writes. The assistant agreed. It also emitted a helper named safeRequest.

safeRequest still accepted a method argument. The first regenerated sample passed DELETE and /v2/purge. The comment above the call said the cleanup would make the next GET deterministic.

The senior kept the comment as evidence. The senior deleted the call. A promise in a chat window is not an allowlist. It is a string the next sample can ignore.

Dead end two: the dry_run query parameter

The next patch added ?dry_run=true to every URL. The assistant said the server would no-op.

They did not believe it. They checked the service’s public docs for a dry-run flag on destructive routes. None existed on anything resembling /v2/purge. A query string the API ignores is not a sandbox. It is a write with extra noise.

Dead end three: generate the client and just see

The junior asked to run the generated script against staging once, to capture headers. The senior refused. Capture is write-adjacent if the client follows redirects or retries with a body.

They needed a host that could not reach billing. They needed the model off the socket. The shared terminal stayed on a laptop with no staging credentials in the environment.

env | grep -Ei 'billing|staging|api_key' || true
# expected: empty. If this prints, pairing stops.
Enter fullscreen mode Exit fullscreen mode

The artifact the pair kept

The pair wrote a tiny gate in JavaScript. It does not call billing. It reads a cassette of intended requests and an allowlist of method-plus-path templates. It exits non-zero when a transcript or a generated script proposes a call outside the list.

The files live next to the client. They do not live in chat history.

allowlist.json

{
  "host": "billing.example.test",
  "allowed": [
    { "method": "GET", "path": "/v1/invoices/:id" },
    { "method": "GET", "path": "/v1/invoices/:id/lines" },
    { "method": "POST", "path": "/v1/invoices/:id/preview" }
  ],
  "deniedVerbs": ["DELETE", "PUT", "PATCH"]
}
Enter fullscreen mode Exit fullscreen mode

POST /preview is treated as read-shaped only because the real service documents it as non-persisting. That exception is written down. It is not inferred by a model.

cassette.json

{
  "name": "billing-read-smoke",
  "calls": [
    {
      "method": "GET",
      "url": "https://billing.example.test/v1/invoices/inv_123",
      "headers": { "accept": "application/json" }
    },
    {
      "method": "GET",
      "url": "https://billing.example.test/v1/invoices/inv_123/lines",
      "headers": { "accept": "application/json" }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

toolcall-gate.mjs

Labeled example for Node 18+. It is a pairing fixture, not a production SDK.

#!/usr/bin/env node
import fs from "node:fs";
import { parseArgs } from "node:util";

const { values } = parseArgs({
  options: {
    allowlist: { type: "string" },
    cassette: { type: "string" },
    transcript: { type: "string" },
  },
});

function fail(msg) {
  console.error(msg);
  process.exit(1);
}

if (!values.allowlist) fail("missing --allowlist");

const allow = JSON.parse(fs.readFileSync(values.allowlist, "utf8"));
const denied = new Set(allow.deniedVerbs || []);

function templateToRegex(path) {
  const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  return new RegExp("^" + escaped.replace(/:([A-Za-z0-9_]+)/g, "[^/]+") + "$");
}

function allowedCall(method, host, pathname) {
  if (denied.has(method)) return false;
  if (host !== allow.host) return false;
  return allow.allowed.some(
    (rule) => rule.method === method && templateToRegex(rule.path).test(pathname)
  );
}

function check(method, url) {
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    fail(`invalid url: ${url}`);
  }
  const verb = method.toUpperCase();
  const ok = allowedCall(verb, parsed.host, parsed.pathname);
  const line = `${verb} ${parsed.host}${parsed.pathname}`;
  if (!ok) fail(`blocked: ${line}`);
  console.log(`ok: ${line}`);
}

if (values.cassette) {
  const cass = JSON.parse(fs.readFileSync(values.cassette, "utf8"));
  for (const call of cass.calls) check(call.method, call.url);
}

if (values.transcript) {
  const text = fs.readFileSync(values.transcript, "utf8");
  const re = /\b(GET|POST|PUT|PATCH|DELETE|HEAD)\s+(https?:\/\/\S+)/gi;
  let match;
  let found = 0;
  while ((match = re.exec(text))) {
    found += 1;
    check(match[1], match[2].replace(/[),.;]+$/, ""));
  }
  if (found === 0) fail("transcript contained no HTTP calls");
}
Enter fullscreen mode Exit fullscreen mode

Commands the pair actually ran

node --check toolcall-gate.mjs

node toolcall-gate.mjs \
  --allowlist allowlist.json \
  --cassette cassette.json

# assistant.http.txt is a saved chat export, not a live tool loop
printf '%s\n' \
  'GET https://billing.example.test/v1/invoices/inv_123' \
  'DELETE https://billing.example.test/v2/purge' \
  > assistant.http.txt

node toolcall-gate.mjs \
  --allowlist allowlist.json \
  --transcript assistant.http.txt
# expected: blocked: DELETE billing.example.test/v2/purge
Enter fullscreen mode Exit fullscreen mode

The transcript mode is a line scanner. It looks for METHOD plus an http URL. It is deliberately dumb. Clever parsers hide verbs in JSON tool payloads. The pair would rather miss a comment than bless a DELETE.

Where a free coding model still helped

After the gate existed, the assistant was useful again. The pair asked it to propose extra GET cassette lines from the client source, not from staging. Every proposal went through toolcall-gate.mjs on a machine that had no billing credentials.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option fit that second half of the pairing: a disposable lane to draft cassette lines and run the gate without pointing the process at staging. The allowlist stayed in the team repo. The model never received production tokens. A team that wants a short-lived lane for the same split can use that free model access and free server option, then keep the fail-closed files they already own.

Decision table the senior left on the whiteboard

Signal in the transcript Model may draft Pair may execute Keep in repo
GET on an allowlisted path Yes, as cassette JSON Only against a host that cannot reach billing Cassette plus gate
POST /preview with a documented no-persist contract Yes, after a human ticks the row Same as GET Allowlist exception with a comment
Any DELETE, PUT, or PATCH No No Denied-verb list
Unknown host, even with GET No No Host pin
?dry_run=true on a route the docs do not define No No Reject the sample
Chat promise to be careful Ignore Ignore Do not store

The table is the pairing signature. The generated client is not.

Limitations, and who should not use this gate

The scanner does not understand OpenAPI, content negotiation, or signed requests. It will bless a GET that still leaks an invoice id in a log. It will miss a write hidden inside a multipart body or a gRPC tunnel. It does not measure latency, and it is not a substitute for contract tests against a real double.

Skip this approach when:

  • The work is exploring an undocumented API and the whole point is live discovery.
  • Compliance forbids sending client source to any hosted model, free or not.
  • The service’s “preview” routes actually persist, despite marketing copy.
  • The team needs load shape, auth refresh, or think-time. This gate does not speak those dialects.
  • Pairing time is shorter than writing the allowlist. Then write two curls by hand and stop.

A free server does not make a blocked verb safe. It only keeps the experiment off staging.

The decision that survived the session

The pairing did not keep a generated billing client. It kept three files and a rule. The assistant may propose GET cassettes. It may not dial. Staging stays parked until a human expands the allowlist.

The invented /v2/purge never shipped. The sticky note stayed on the monitor.

Top comments (0)