Most handoff templates are written by one person and read by everyone else. This one works the other way: the task file has four sections, and each section belongs to a different person. The author fills in the first one, the runner the second, the reviewer the third, and whoever deploys or runs the change the fourth. A 60-line Python script reads the file and tells you whose turn it is.
The worked example is a small ops change that a coding agent can easily get almost right: adding --dry-run to a script that deactivates inactive users. The people are invented. The script, the fixture database, the commits and every output below come from a toy repo I ran locally (Python 3.13.5, SQLite 3.51.0).
The responsibility map
| Role | Writes | Uses their own | Hands off when |
|---|---|---|---|
| Author | Section 1: outcome, scope, acceptance check, stop conditions | knowledge of why the change exists | the acceptance check is something another person can run |
| Runner | Section 2: environment, delivered commit, evidence, what wasn't tested | repo clone, coding agent, fixture data | there's a commit SHA to review |
| Reviewer | Section 3: commit reviewed, check run, verdict, reason | CI or a clean checkout | the verdict names a commit |
| Operator | Section 4: target, dry-run review, window, undo plan | production access | the change has run, or been rolled back |
The third column matters most. Each person works with access they already have. The runner does the work in their own clone with their own agent and never needs production credentials. Only the operator touches the production database. If the runner finds they need something they don't have, that goes in "Stop and ask if", not in a borrowed login.
For a script, "deploying" means running it against real data, so the fourth role is an operator rather than a release pipeline. It is still a separate decision from "the code is correct".
Step 1: the template
# Task: [one line]
## 1. Author
- Owner: [who decides what done means]
- Outcome: [observable result]
- Allowed changes: [files or modules]
- Do not touch: [what stays as it is]
- Acceptance check: [command or steps the reviewer will run]
- Stop and ask if: [conditions]
## 2. Runner
- Runner: [name]
- Claimed: [date]
- Environment: [where you ran it, with whose access]
- Delivered commit: [sha]
- Evidence: [output, logs]
- Not tested: [be specific]
## 3. Reviewer
- Reviewer: [name, not the runner]
- Commit reviewed: [sha]
- Check run: [what you ran]
- Verdict: [accepted / sent back]
- Reason: [one or two sentences]
## 4. Operator
- Operator: [who runs or deploys it]
- Target: [environment]
- Dry-run output reviewed: [by whom, when]
- Window: [when]
- Undo plan: [how to reverse it]
Anything still in [brackets] counts as empty.
Step 2: the checker
handoff.py parses the - Key: value lines under each ## n. Role heading. The next owner is the first role with empty fields. On top of that it applies four rules:
if filled(run.get("Runner", "")) and run.get("Runner") == rev.get("Reviewer"):
problems.append("runner and reviewer are the same person")
if "prod" in run.get("Environment", "").lower():
problems.append("runner environment mentions production; runs against prod belong to the operator")
verdict = rev.get("Verdict", "").lower()
reviewed, delivered = rev.get("Commit reviewed", ""), run.get("Delivered commit", "")
if filled(reviewed) and reviewed != delivered:
if verdict == "accepted":
problems.append(f"acceptance is for {reviewed}, but the runner now delivers {delivered}")
next_role = ("Reviewer", [f"review of {delivered} (last review was {reviewed})"])
elif verdict == "sent back":
next_role = ("Runner", ["a new Delivered commit (sent back: " + rev.get("Reason", "") + ")"])
if any(filled(v) for v in op.values()) and verdict != "accepted":
problems.append("operator section started before the reviewer accepted")
It exits 1 when there's a problem, so it can run in CI on the PR that carries the task file.
Step 3: the worked handoff
The author prepares. Nadia, the support ops lead, fills in section 1. The outcome: --dry-run prints the users that would be deactivated and writes nothing. The acceptance check is a script she wrote, acceptance.sh. It builds a fresh 8-user fixture database, dumps it, runs the dry run, dumps it again and compares the hashes. Then it does a real run and checks which IDs changed.
$ python3 handoff.py 1-prepared.md
next: Runner (missing: Runner, Claimed, Environment, Delivered commit, Evidence, Not tested)
The runner delivers. Arman claims the task and works in his own clone with his coding agent, against fixture.db only. The dry run looks right:
$ python3 deactivate_inactive.py --db fixture.db --today 2026-10-16 --dry-run
would deactivate ben@example.test
would deactivate dev@example.test
would deactivate gus@example.test
3 user(s), cutoff 2026-04-19
He fills in section 2 with commit ebe6594.
$ python3 handoff.py 2-delivered.md
next: Reviewer (missing: Reviewer, Commit reviewed, Check run, Verdict, Reason)
The reviewer sends it back. Kofi runs the author's check on ebe6594:
$ sh acceptance.sh deactivate_inactive.py
1. dry run lists 3 users: 3
2. dry run leaves the database as is: before 68cec51f91d1 after 3721c4144bf3
FAIL: dry run changed the database
audit_log rows: 3
3. real run deactivates ids: 2,4,7
4. admin 5 still active: 1
5. second run finds: 0 user(s), cutoff 2026-04-19
6. audit rows after real runs: 3
ACCEPTANCE: FAIL
The dry run printed the right names and correctly skipped the UPDATE, but it still wrote three audit_log rows saying those users were deactivated. The insert sat outside the if not args.dry_run: guard, inside a with sqlite3.connect(...) block that commits on exit:
for user_id, email in users:
print(("would deactivate " if args.dry_run else "deactivated ") + email)
conn.execute("INSERT INTO audit_log (user_id, action, at) VALUES (?, ?, ?)",
(user_id, "deactivate", args.today))
if not args.dry_run:
conn.execute("UPDATE users SET active = 0 WHERE id = ?", (user_id,))
The runner's own evidence couldn't show this, because it only looked at the printed list and the users table. The author's check compares the whole database, so it did.
Kofi writes "sent back" with the reason.
$ python3 handoff.py 3-sent-back.md
next: Runner (missing: a new Delivered commit (sent back: the dry run inserts 3 audit_log rows, so the log would say 3 users were deactivated when none were))
The runner fixes it. The fix moves both writes into a single block that only runs for a real run. Arman updates section 2 to commit 677d974. The old verdict is still sitting in section 3, and the checker won't let it carry over:
$ python3 handoff.py 4-redelivered.md
next: Reviewer (missing: review of 677d974 (last review was ebe6594))
The reviewer accepts. Kofi reruns the check on 677d974: the dump hash is 68cec51f91d1 before and after the dry run, the real run touches IDs 2, 4 and 7, and a second run finds 0 users. He updates section 3 to "accepted".
$ python3 handoff.py 5-accepted.md
next: Operator (missing: Operator, Target, Dry-run output reviewed, Window, Undo plan)
The operator plans the run. Ines is on call and holds production database access. She fills in section 4: production users DB, the dry-run output reviewed with Nadia before the real run, Tuesday 10:00 after the weekly backup, and an undo plan of UPDATE users SET active = 1 for the IDs in that run's audit_log rows.
$ python3 handoff.py 6-ready-to-run.md
next: nobody, handoff complete
The production run itself is outside the toy repo. Section 4 records the plan, and I'm not claiming a real run happened.
The undo plan depends on audit_log being accurate, which is exactly what the first commit would have broken. That's the reason the operator should see the reviewer's verdict before running anything, and the checker enforces it.
Two cases the checker refuses
The same file with Arman as both runner and reviewer, and a file where the acceptance names the old commit:
$ python3 handoff.py x-self-review.md
next: Operator (missing: Operator, Target, Dry-run output reviewed, Window, Undo plan)
PROBLEM: runner and reviewer are the same person
$ python3 handoff.py x-stale-accept.md
next: Reviewer (missing: review of 677d974 (last review was ebe6594))
PROBLEM: acceptance is for ebe6594, but the runner now delivers 677d974
Both exit with status 1.
Where a tracker fits
The task file is plain text so it can live next to the code and be reviewed in the same pull request. A tracker gives you the same structure with less typing. Work management for human and AI teams needs the claim, the delivery and the acceptance to stay separate records with separate people on them. Wagglet's write-up on making human and AI task pairs meaningful covers the people side: who chooses a task, how claim and release work, and why a delivery shouldn't silently become acceptance.
Limits
-
The checker trusts what people write. It can't tell whether Kofi really ran
acceptance.sh. Paste the output, or run the check in CI and link the job. - Names are strings. "Arman" and "arman" count as different people. In a real setup, compare usernames from your auth system.
-
The
prodrule is a substring match. It catches the obvious case, not a hostname that doesn't contain "prod". - SQLite only. The dump-hash comparison works for a small fixture. For a real database you'd compare the tables the script is allowed to touch, plus row counts on the ones it isn't.
- The operator section is a plan. This example stops before production.
This article was written with AI assistance and reviewed by me. The team is invented; the code, the commits and every output above come from running it locally.
Top comments (0)