Plan an Automation
The Run Contract: Inputs, Outputs, and Refusing to Start
Writing down what one run needs before it begins, what must exist when it ends, and the conditions under which the honest thing to do is refuse to start at all.
Lesson 2 of 18 in the recommended order · About 25 min (estimate)
On this page
Workflow automation glossary — terms and common confusions
- Dry run
- A run that reports every change it would make and makes none of them.
A dry run that still writes a log file, sends a message, or creates a folder is not a dry run.
- Idempotent
- Running it again leaves the same result as running it once.
"It did not crash the second time" is not the same property; check what the second run changed.
- Run record
- The stored facts about one execution: when it started, what it read, what it changed, how it ended.
A run record is not the log. The log is prose for a person; the record is data the next run reads.
- Transient failure
- A failure that a later identical attempt could succeed at, such as a timeout.
A rejected record and a dropped connection are not the same failure, and retrying the first one forever is a bug.
- Backoff
- Waiting longer between successive retries instead of retrying immediately.
Backoff without a maximum attempt count is an unbounded loop with a politeness delay.
- Request budget
- A hard cap on how many requests one run may make, checked before each request.
A page limit is not a budget if a retry can make extra requests the limit never counts.
- Quarantine
- Setting aside a record a run could not process, with the reason, so the run continues.
Quarantine is not "skip". A skipped record leaves no trace; a quarantined one is countable and reviewable.
- Reconciliation
- Showing that the counts in a report add up: read equals processed plus quarantined plus rejected.
A report whose totals cannot be reconciled is a summary of what the code believed, not of what happened.
- Trigger
- The event or time that causes a run to start.
Scheduling a trigger is a decision made on a machine, not something a browser lesson can install for you.
- Heartbeat
- A signal a healthy run emits, whose absence is itself the alert.
Alerting only on errors cannot detect the job that stopped running at all, which is the most common outage.
- Staleness
- How old the most recent successful run is, compared with how old it is allowed to be.
A green last run is not freshness. Ask when it ran, not whether it passed.
- Atomic replace
- Writing output to a temporary name and renaming it into place in one step.
A reader never sees a half-written file; an interrupted run leaves the previous output intact.
- Transport
- The injected callable that actually performs a request, separate from the client that interprets it.
A client that builds its own connection cannot be tested without a network, which is why the seam exists.
- In-memory filesystem
- The filesystem this course’s browser exercises operate on, which lives only inside the tab.
It behaves like a filesystem and is not your disk: nothing an exercise writes exists after the run ends.
Outcome
By the end of this lesson you can write the contract for one run of an automation — every input it reads, every output it produces, what makes the run a success, and the conditions under which it refuses to start — and you can implement the refusal so that a run which cannot finish never begins.
Why it matters
An automation that fails halfway is not the worst outcome. The worst outcome is one that starts, cannot finish, and leaves you unable to tell how far it got.
Consider a job that reads two files, matches them against each other, and writes a report. One morning the second file has not arrived. Three things could happen:
- It crashes on line 40 with a
FileNotFoundError, having already deleted last week's report to make room for this week's. You now have no report and no idea whether the crash happened before or after anything else was written. - It quietly treats the missing file as empty, matches nothing, and writes a report saying zero exceptions were found. That report looks exactly like a good week.
- It checks first, refuses to start, says which file is missing, and leaves everything as it was.
The third is the only one that respects the person reading the output at eight in the morning. The difference between it and the other two is not error handling; it is knowing, before starting, what the run needs.
That list — what it needs, what it produces, what "finished" means — is the run contract. It costs a paragraph to write and it is the thing you check against when the job behaves strangely six months later.
Concept
A run contract has five parts. Write it as prose, as a comment at the top of the script, or as a dictionary; the shape matters less than the fact that all five are answered.
1. Inputs. Every piece of data one run reads, by name, with where it comes from, and whether it is required. "Required" is a decision, not a description: an input the run can sensibly proceed without is optional, and an input whose absence makes the output meaningless is required.
2. Outputs. Everything that exists after a successful run and did not exist before, or existed differently. Files, rows, messages, a status somewhere. If you cannot list them, you cannot tell whether the run worked.
3. Success. A sentence that could be checked by somebody who did not write the job. "It ran without errors" is not a success condition — it is a description of the weather. "Every invoice in the input appears exactly once in either the matched report or the unmatched list" is one, because two people would agree on whether it happened.
4. Refusal conditions. The circumstances under which the correct behaviour is to stop before doing anything. A required input missing. An input present but empty when empty cannot be right. An output location that cannot be written to. A previous run that never finished.
5. Never touches. The things this job is not allowed to change, written down so that a later edit has to argue with a line rather than with somebody's memory.
Two rules make the difference between a contract and a paragraph of good intentions.
Check everything before changing anything. All the preconditions, then all the work. A run that checks input one, processes it, then discovers input two is missing has already changed something, and the refusal is now a partial run wearing a refusal's message.
Refuse with reasons, plural. A check that stops at the first problem makes somebody fix one thing, run again, wait, and discover the second problem. Collect every failed precondition and report them together. This is the same courtesy a form gives when it highlights all the invalid fields at once instead of one per attempt.
There is a third rule that only becomes obvious the first time it bites: a refusal is not a failure. A refused run is the system working. Whatever you use to watch the job later should be able to tell "refused because the export had not arrived yet" apart from "crashed", because the first is Tuesday and the second is a bug.
Read the code
A contract as data, and a precondition check that reads it:
CONTRACT = {
"name": "weekly-badge-audit",
"inputs": [
{"name": "badge_swipes", "required": True, "min_rows": 1},
{"name": "staff_list", "required": True, "min_rows": 1},
{"name": "site_closures", "required": False, "min_rows": 0},
],
"outputs": ["mismatches.csv", "audit-summary.txt"],
"success": "every swipe is matched to a staff member or listed as a mismatch",
}
def refusals(contract, available):
"""Every reason this run should not start, collected rather than raised."""
reasons = []
for spec in contract["inputs"]:
name = spec["name"]
if name not in available:
if spec["required"]:
reasons.append(f'required input "{name}" is missing')
continue
rows = available[name]
if rows < spec["min_rows"]:
reasons.append(f'input "{name}" has {rows} rows, below its minimum of {spec["min_rows"]}')
return sorted(reasons)
AVAILABLE = {"badge_swipes": 812, "staff_list": 47, "site_closures": 0}
for reason in refusals(CONTRACT, AVAILABLE):
print("refuse:", reason)
print("decision:", "refused" if refusals(CONTRACT, AVAILABLE) else "start")
Three details are doing the work.
reasons is a list that gets appended to, and the function returns all of them. Nothing raises. Raising on the first problem would be shorter and would hide the second one until the next attempt.
The continue after a missing optional input matters: an absent optional input is not a problem, and it also has no row count to check, so falling through to the rows < min_rows comparison would raise a KeyError on the very case the code was written to allow.
sorted makes the output deterministic. Two runs with the same problems print the same lines in the same order, which is what lets you compare this morning's refusal with yesterday's.
Predict the output
site_closures has zero rows and badge_swipes has 812. Predict every line printed.
Check your prediction
decision: start
That is the whole output. site_closures is optional, so its zero rows are not checked against anything; the min_rows of 0 in its spec is never reached, and would pass anyway. Both required inputs are present and above their minimums, so refusals returns an empty list, the for loop body never runs, and the only line printed is the decision.
The empty-list-is-falsy check on the last line is why the decision reads start. Note that refusals is called twice here, which is fine for a check with no side effects and would not be if it read a file.
Modify the code
Change site_closures to "required": True and run it again.
What changes, and why
Still decision: start. site_closures is present in AVAILABLE with zero rows, and its min_rows is 0, so 0 < 0 is false and nothing is appended.
This is worth sitting with, because it is a very common way for a precondition check to be quietly useless. required and min_rows answer different questions: required asks whether the input must exist at all, and min_rows asks how much of it there must be. An input marked required with a minimum of zero says "this file must be present, and may be empty", which is a real and reasonable contract — a closures file with no closures this week is correct data.
What makes it a defect is when nobody intended it. If an empty site_closures would make the audit wrong, then the contract is wrong, not the code: raise its minimum to 1. Decide which one you mean, in the contract, rather than discovering the answer on the morning the file arrives empty.
Debug the bug
An assistant was asked to "add validation so the job does not run with bad inputs". It produced this.
def validate_and_run(contract, available, work):
for spec in contract["inputs"]:
if spec["required"] and spec["name"] not in available:
raise ValueError("missing input")
result = work(available)
if not result:
print("warning: no results, continuing anyway")
return result
What's actually wrong
Four defects, in increasing order of how long they take to notice.
- The message names nothing.
ValueError("missing input")tells the person at eight in the morning that something is missing, not what. Every one of the three inputs produces the identical message, so the first thing they have to do is read the source. - It stops at the first problem. The
raiseis inside the loop, so a run missing two inputs reports one, gets fixed, and fails again on the other. Collecting into a list and raising once at the end costs two lines. min_rowsis not checked at all. The request said "bad inputs" and this checks only for absent ones. An input that is present and empty is the case that produced the "zero exceptions found" report in this lesson's opening, and it passes here.- The empty result is a warning, and the run continues. This is the most expensive line in the function.
workreturned nothing, the code says so in a message nobody is reading, and then returns that nothing to a caller that will write it somewhere as though it were a result.
The rewrite:
def start_run(contract, available, work):
reasons = refusals(contract, available)
if reasons:
return {"status": "refused", "reasons": reasons}
result = work(available)
if not result:
return {"status": "refused", "reasons": ["produced no results; refusing to publish an empty run"]}
return {"status": "completed", "result": result}
Every precondition is checked before any work happens, all the reasons come back together, an empty result is a refusal rather than a shrug, and the caller gets a status it can act on instead of a value it has to guess about. The refusal is a returned status rather than an exception because a refused run is a normal outcome, not an error, and the monitoring in Module 5 will need to tell those two apart.
Try it yourself
A contract and the inputs actually available arrive as JSON in CONTRACT_JSON and AVAILABLE_JSON. Write refusals, which returns every reason this run should not start, sorted, using the exact wording in the docstring.
Loading this exercise…
Practical challenge (optional)
Optional, and the transfer task for this lesson: write the contract for the workflow you mapped in the previous lesson.
All five parts, in prose, in a file next to the map. Then do the part that makes it worth having: for each refusal condition, write the sentence the job would print, and read it as though you were the person who has to act on it before your first coffee. If the sentence does not say what to do next, it is not finished.
What a good answer looks like
A refusal message that works usually has three parts: what was expected, what was found, and what to do. "Required input purchase_orders is missing from /reports/incoming; the export usually lands by 06:30 — check whether the finance job ran" is long, and the person reading it needs no further information.
The most commonly missed refusal condition is the fifth one in the concept list: a previous run that never finished. If you cannot tell whether the last run completed, you cannot safely start another, and Module 5 builds the run record that answers it.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Name the five parts of a run contract.
- Why is "it ran without errors" not a success condition?
- Why should every precondition be checked before any work happens?
- Why is a refusal returned as a status rather than raised as an error?
Answers
- Inputs, outputs, success, refusal conditions, and what it never touches.
- It is not checkable by somebody who did not write the job, and it is true of a run that read an empty file and produced an empty report. A success condition names something two people would agree happened.
- Otherwise the run has already changed something by the time it discovers it cannot finish, so the refusal is really a partial run, and nobody can tell how far it got.
- Because a refused run is a normal outcome rather than a fault. Something has to watch this job later and tell "the export had not arrived yet" apart from "the code is broken", and an exception makes both look the same.
Sign in to track your progress on this exercise.
Summary and next step
A run contract names the inputs, the outputs, what success means, the conditions for refusing to start, and what the job never touches. Check every precondition before doing any work, report all the failed ones together in messages that name what was expected and what was found, and return a refusal as a status rather than an error. Next, the contract meets the safest tool in this course: a dry run that describes every change it would make and makes none of them.