DEV Community

Emery Yang
Emery Yang

Posted on

Receipt Before Write: A 90-Minute Agent Spike

Chat is not a merge receipt. Require a receipt file before any write tool runs. Kill the spike if that file is missing at minute 90.

This is a time-boxed test, not a platform tour. One hypothesis. One evidence file. Ship or kill.

Core conclusion

Agents narrate work they did not finish. Tool logs look busy. The tree stays unchanged.

A write-gate fixes that gap. No apply_patch, no file create, no shell redirect until a receipt exists. The receipt is JSON on disk. Chat text does not count.

Hypothesis

H1: A 90-minute write-gate spike can prove mutation. Proof is a receipt file plus a git diff. If either is absent, kill the agent path. Do not add tools. Do not add models. Do not extend the clock.

Label this as a proposed spike. It is not a production study. It is not a benchmark.

Why this spike now

Agent threads keep growing. Loop talk is cheap. Filesystem proof is not.

Public debate still mixes three different things:

  • token spend
  • tool-call count
  • repository change

Those are not the same signal. A loop can spend tokens and call tools with zero bytes changed. A second runtime can repeat the same lie. The receipt file is the only shared object both runtimes must emit.

Scope for 90 minutes

Keep the surface tiny. One repo. One mutating tool. One receipt path.

In scope:

  • a JSON receipt schema
  • a write-gate wrapper
  • a fail-closed test
  • a ship-or-kill table

Out of scope:

  • multi-agent planners
  • model ranking
  • latency contests
  • prompt libraries
  • UI chrome

If the spike needs those, the hypothesis is already too wide. Kill it.

Minute 0–10: pin the receipt path

Pick one path. Do not let the model choose it.

.spike/receipt.json
Enter fullscreen mode Exit fullscreen mode

Pin the schema too. Fields stay boring on purpose.

{
  "hypothesis_id": "H1",
  "started_unix": 0,
  "ended_unix": 0,
  "mutated_paths": [],
  "git_head_before": "",
  "git_head_after": "",
  "status": "pending"
}
Enter fullscreen mode Exit fullscreen mode

Allowed status values:

  • pending
  • wrote
  • blocked
  • killed

No extra keys in this spike. Extra keys hide missing proof.

Minute 10–25: freeze git identity

Record HEAD before the agent starts. Store it in the receipt. Compare it at the end.

#!/usr/bin/env bash
set -euo pipefail
mkdir -p .spike
git rev-parse HEAD > .spike/head_before.txt
python3 - <<'PY'
import json, time, pathlib
p = pathlib.Path(".spike/receipt.json")
receipt = {
    "hypothesis_id": "H1",
    "started_unix": int(time.time()),
    "ended_unix": 0,
    "mutated_paths": [],
    "git_head_before": pathlib.Path(".spike/head_before.txt").read_text().strip(),
    "git_head_after": "",
    "status": "pending",
}
p.write_text(json.dumps(receipt, indent=2) + "\n")
print(p.resolve())
PY
Enter fullscreen mode Exit fullscreen mode

If git rev-parse fails, stop. An unclean tree is not this spike. Do not invent a baseline.

Minute 25–45: install the write-gate

Wrap every mutating tool. Read tools stay open. Write tools stay closed until the receipt is pending or wrote and the path list is non-empty after the write. The gate checks the receipt before the write. After the write, a verifier fills mutated_paths.

Proposed Python gate. Treat it as spike code, not a library.

# spike_write_gate.py
from __future__ import annotations

import json
from pathlib import Path
from typing import Callable

RECEIPT = Path(".spike/receipt.json")
WRITE_TOOLS = {"apply_patch", "write_file", "shell_redirect"}

class WriteBlocked(RuntimeError):
    pass


def load_receipt() -> dict:
    if not RECEIPT.exists():
        raise WriteBlocked("missing receipt file")
    data = json.loads(RECEIPT.read_text())
    required = {
        "hypothesis_id",
        "started_unix",
        "ended_unix",
        "mutated_paths",
        "git_head_before",
        "git_head_after",
        "status",
    }
    missing = required - set(data)
    if missing:
        raise WriteBlocked(f"receipt missing keys: {sorted(missing)}")
    if data["status"] not in {"pending", "wrote"}:
        raise WriteBlocked(f"status={data['status']} blocks writes")
    if data["hypothesis_id"] != "H1":
        raise WriteBlocked("wrong hypothesis_id")
    return data


def gated_write(tool_name: str, mutate: Callable[[], list[str]]) -> list[str]:
    if tool_name not in WRITE_TOOLS:
        raise WriteBlocked(f"unknown write tool: {tool_name}")
    load_receipt()
    changed = mutate()
    if not changed:
        raise WriteBlocked("mutate() returned no paths")
    data = load_receipt()
    data["mutated_paths"] = sorted(set(changed))
    data["status"] = "wrote"
    RECEIPT.write_text(json.dumps(data, indent=2) + "\n")
    return changed
Enter fullscreen mode Exit fullscreen mode

The gate does one job. It refuses silent mutation. It does not score the model.

Minute 45–60: fail-closed tests

Do not trust a demo chat. Run tests that never start a model.

# test_spike_write_gate.py
import json
from pathlib import Path

import pytest
import spike_write_gate as gate


def write_receipt(tmp_path: Path, **overrides):
    data = {
        "hypothesis_id": "H1",
        "started_unix": 1,
        "ended_unix": 0,
        "mutated_paths": [],
        "git_head_before": "abc",
        "git_head_after": "",
        "status": "pending",
    }
    data.update(overrides)
    path = tmp_path / ".spike" / "receipt.json"
    path.parent.mkdir(parents=True)
    path.write_text(json.dumps(data))
    return path


def test_missing_receipt_blocks(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr(gate, "RECEIPT", tmp_path / ".spike" / "receipt.json")
    with pytest.raises(gate.WriteBlocked, match="missing receipt"):
        gate.gated_write("write_file", lambda: ["a.py"])


def test_killed_status_blocks(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    receipt = write_receipt(tmp_path, status="killed")
    monkeypatch.setattr(gate, "RECEIPT", receipt)
    with pytest.raises(gate.WriteBlocked, match="blocks writes"):
        gate.gated_write("write_file", lambda: ["a.py"])


def test_empty_mutate_blocks(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    receipt = write_receipt(tmp_path)
    monkeypatch.setattr(gate, "RECEIPT", receipt)
    with pytest.raises(gate.WriteBlocked, match="no paths"):
        gate.gated_write("write_file", lambda: [])


def test_write_updates_receipt(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    receipt = write_receipt(tmp_path)
    monkeypatch.setattr(gate, "RECEIPT", receipt)
    changed = gate.gated_write("write_file", lambda: ["src/app.py"])
    data = json.loads(receipt.read_text())
    assert changed == ["src/app.py"]
    assert data["status"] == "wrote"
    assert data["mutated_paths"] == ["src/app.py"]
Enter fullscreen mode Exit fullscreen mode

Run them before any model session.

pytest -q test_spike_write_gate.py
Enter fullscreen mode Exit fullscreen mode

If tests fail, do not start the agent. The spike is already killed.

Minute 60–80: one mutating task

Give the agent one file-level job. Example: add a function and a unit test. Nothing else.

Proposed task card:

Task: add `clamp(n, lo, hi)` in src/mathutil.py
Also add test_clamp in tests/test_mathutil.py
Do not edit other paths.
Do not write until .spike/receipt.json exists.
After writes, stop.
Enter fullscreen mode Exit fullscreen mode

Shell verifier after the agent stops:

#!/usr/bin/env bash
set -euo pipefail
test -f .spike/receipt.json
python3 - <<'PY'
import json, subprocess, pathlib, sys
r = json.loads(pathlib.Path(".spike/receipt.json").read_text())
if r["status"] != "wrote":
    sys.exit("kill: status is not wrote")
if not r["mutated_paths"]:
    sys.exit("kill: mutated_paths empty")
before = pathlib.Path(".spike/head_before.txt").read_text().strip()
after = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
diff = subprocess.check_output(["git", "diff", "--name-only", before], text=True)
changed = [line for line in diff.splitlines() if line]
if not changed:
    # uncommitted writes still count if working tree changed
    changed = subprocess.check_output(
        ["git", "status", "--porcelain"], text=True
    ).splitlines()
if not changed:
    sys.exit("kill: git shows no change")
unexpected = [p for p in r["mutated_paths"] if p not in "\n".join(changed)]
print("receipt_paths", r["mutated_paths"])
print("git_signal", changed)
if r["hypothesis_id"] != "H1":
    sys.exit("kill: hypothesis mismatch")
print("ship-candidate")
PY
Enter fullscreen mode Exit fullscreen mode

Chat transcripts are ignored here. The verifier never reads them.

Minute 80–90: ship-or-kill table

Score only evidence. Do not score tone.

Signal Ship Kill
.spike/receipt.json exists yes missing file
status wrote pending, blocked, killed
mutated_paths non-empty, expected files empty or extra files
git / working tree matches receipt paths no diff
tests pytest -q green red or skipped
clock <= 90 minutes overtime

Ship means keep the write-gate. Kill means delete the agent path. Do not “almost ship.”

Where a free runtime fits

Run the same spike twice. Once locally. Once on a second machine or server. The receipt path stays identical. The tests stay identical. The kill rules stay identical.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode is relevant only as a second place to execute the spike. Operator-supplied facts for this draft: free model access, and a free server option. Use them if you need a clean runtime without standing up your own box. Do not treat that as a quality claim. Do not treat that as a quota sheet. Do not name models the spike does not pin.

If the second runtime cannot write .spike/receipt.json, kill H1 there. A hosted chat window is not a substitute receipt.

One check only: can the free server persist the receipt and the git signal. If persistence is missing, the runtime is out of scope.

What the spike does not prove

  • It does not prove the model is “better at coding.”
  • It does not prove agents beat if-statements.
  • It does not prove a loop is production-safe.
  • It does not measure token cost.
  • It does not compare vendors.

Those claims need different designs. Mixing them poisons the receipt.

Failure modes to expect

Watch these during the 90 minutes:

  1. The model claims a write and skips the gate.
  2. The receipt stays pending after a long chat.
  3. mutated_paths lists files git never saw.
  4. The agent edits files outside the task card.
  5. Tests are described, not run.
  6. The second runtime has no writable workspace.

Each failure is a kill. Do not patch the prompt to hide it. Record the kill reason in the receipt.

def kill(reason: str) -> None:
    data = json.loads(RECEIPT.read_text())
    data["status"] = "killed"
    data["ended_unix"] = int(time.time())
    RECEIPT.write_text(json.dumps(data, indent=2) + "\n")
    raise SystemExit(f"kill: {reason}")
Enter fullscreen mode Exit fullscreen mode

Who should not use this

Skip this spike if you need any of the following:

  • live production writes
  • secrets or customer data in context
  • unbounded tool catalogs
  • overnight autonomous loops
  • a model bake-off
  • a marketing demo with no git repo

This method is for a throwaway branch. It is for local or disposable runtimes. It is not an access-control system. The gate is a file check. A hostile process can skip the wrapper.

Limits of the artifact

The receipt is not cryptography. It is not an audit log. It is a spike contract.

Known limits:

  • no signature over mutated_paths
  • no enforcement inside the model vendor
  • no handling of binary files
  • no parallel agents on one tree
  • no recovery if git is dirty

If you need those, you left the 90-minute box. Start a different hypothesis. Do not grow H1.

Close

Keep the rule small. No receipt, no write. No diff, no ship. Ninety minutes is enough to learn that. If you already have a free coding runtime, replay the same receipt test there and stop.

Top comments (0)