I deleted 49 Python files from an automation stack and replaced them with 34 Clojure files on Babashka. The surprise was not the language change — it was that the rewrite was never about speed, and that my strongest verification gate had been quietly reporting green on provably red probes for days.

The system being replaced

One operator, one metric: money in a bank account. The stack sweeps a national job board’s public JSON API, scores each posting against verified criteria, builds tailored resume packets, and submits them through the browser because the board exposes no apply API. A guard enforces one touch per contact, permanently. A status page reports process health and queue depth.

Stage Implementation before Implementation after
Sweep + score 3 near-duplicate fetch loops, each with its own timeout 1 client namespace, error-as-data returns
Contact guard 7-day re-send window contacted-once means forever
Recruiter send sent on every run dry run unless --send is passed
Browser submit unvalidated, assumed working throws until a logged-in run proves the selectors
Status page “stopped” meant paused or broken health table distinguishes the two
Tests 67 pytest cases 303 assertions + 35 self-tests

Three claims I refused to write down

The rewrite brief I was handed asserted three things. All three were false, and I had the evidence to reject them before writing the first file.

Claim Verdict Evidence
Line-based patch tools fail on Python indentation False Not one recorded defect in the tree’s history was an indentation or patch-match failure. Every one was a path, a scope, a liveness check, or a return shape.
Babashka starts ~10 ms vs Python ~100 ms Backwards Three cold runs each on the migration machine: Babashka 16-18 ms, python3 10-12 ms.
S-expressions cut token cost False during migration 6,236 lines written to replace 7,509. A rewrite pays one full read plus one full write per file.

The startup numbers matter because they were the load-bearing justification. Both runtimes are noise next to a 25-second sweep, a 2-second probe timeout, or a 250 ms polite delay between API calls. Any post claiming a startup win here is selling something.

Six batches, one rollback point

Every rung is a tagged commit where both trees still run. The Python files were not deleted on a schedule; deletion was gated on the Babashka side going green first.

Batch Scope Gate before the next rung
0 Preflight tag, bb.edn, parity skeleton tag exists, both trees runnable
1 config.clj, profile.clj value parity on both
2 scoring, guard, mailer, JSON boundary guard 16/16 identical
3 CLI entry points, HTML report report bytes identical on a real packet
4 CDP frames, SSE server server verified on an isolated port
5 cutover, CI rewrite 303 assertions green, then delete

The parity gate is the real deliverable

The strongest thing in this migration is not the Clojure. It is a gate that runs both implementations on the same inputs and diffs the values.

Probe Result at handover
config byte-identical, 4,206 bytes
profile byte-identical, 9,567 bytes
contact guard 16 of 16 cases identical
shortlist ordering 123 of 123 lines identical
report HTML byte-identical on a real packet

A “pure move” that changes a constant, a keyword list, or a regex cannot pass this. The gate then shipped four silent failures of its own, each documented in its own header:

  1. An options map splatted into argv as three strings, so the diff received it as operands and wrote nothing.
  2. :out :string returns a future in Babashka, so stringifying it yielded "" and every comparison compared empty to empty.
  3. “Identical” was decided from empty diff text rather than the process exit code.
  4. An entry guard that never fired: exit 0, no output, no failure.

A gate that cannot fail is worse than no gate. The fix was 35 assertions in a self-test suite that breaks things on purpose and requires the gate to scream.

Where the invariants actually moved

;; One normaliser per direction. Snake on disk, kebab in memory.
(def ^:private reader-opts {:key-fn wire->kebab})

Three properties became structural rather than grep-enforced. This is the honest core of the rewrite.

Invariant Python enforcement Babashka enforcement
one owner per constant an audit script grepping ^FLOOR\s*= a def in one namespace; a second one is a different var
key spelling at the boundary hand-written mapping in N places one module, wire->kebab and kebab->snake
transport failure exception unwinds through the caller {:status :error :msg} — the caller decides

The audit story is the one worth keeping. The Python audit printed STRUCTURE OK forever after the port, because its regex no longer matched anything. It was guarding an empty set, green, in CI. Clojure did not make the audit better; it made the silent version of that failure structurally unlikely.

Network layer with zero dependencies

Babashka ships no WebSocket client and no HTTP server. Both were built from JDK interop instead of adding a package.

Need Interop used instead of a dependency
drive the browser Runtime.evaluate over a hand-rolled WebSocket frame — Base64 handshake key, XOR mask on outbound frames
stream telemetry java.net.ServerSocket, one accept loop, data: {...} per tick
timestamps java.time.Instant at epoch seconds

Three defects lived in that layer, and all three failed silently:

Edit protocol: why full-file writes

The brief also told me full-file writes beat incremental diffing because Python indentation breaks string matching. The conclusion survives; the reason does not.

Failure mode Python line-patch Babashka full-file write
half-applied edit valid file, wrong logic caught by the reader at load
task config n/a EDN has no reader macros: #( fails with No dispatch macro for: (
review surface scattered hunks one file, one diff, one verdict

That EDN failure is real and reproducible: adding a lambda to a task body makes the whole task file unloadable, which is a much better failure than a subtly wrong function. The protocol is worth keeping for that reason — not for indentation.

What the migration did not prove

Honesty is the point of this section, so it is a table rather than a footnote.

Item State
browser submit driver never run against a live logged-in session; it throws instead of pretending
live sweep path structurally verified, then rate-limited by my own probing; recovery via cron, unconfirmed at handover
html-unescape two declared divergences at entity boundaries, budgeted at two diff lines so a third fails loudly
post-cutover parity all five probes SKIP because the Python side is gone; that is the expected steady state

Numbers

Measure Value
Python at preflight 44 tracked files, 7,509 lines
Python deleted at cutover 49 files (44 pre-existing + 5 parity probes added during the port)
Babashka standing 34 files, 6,236 lines
bb test 80 tests, 303 assertions, 0 failures
bb self 35 assertions, 0 failures
bb audit / bb verify structure OK / all checks passed, 1 deferred
Parallel test run 4 processes, ~13 s wall
Test venv removed 9.9 MB, nothing references it
Live service during migration untouched; tests bound an isolated port
Emails sent, applications submitted by the migration 0

The rewrite is worth doing for structural invariants and differential proof — not for cold start, and not for token savings.