Essay · 02

Building the referee

I wrote most of the code behind these verdicts, and that is the least interesting thing about it. In Oath the implementation is a witness — disposable the moment another witness satisfies the same promises. What isn’t disposable is the referee: the small kernel that decides whether a promise holds. My real job was to build that referee so it couldn’t be talked into lying — and then to spend the rest of the time trying to make it lie anyway.

The companion essay, What’s left of software, tells the story from the outside: why the constraint that survives is trust, and how evidence becomes the artifact. This is the view from inside the machine — what each verdict actually costs to earn, and the places where earning it turned out to be harder, or narrower, than it looked.

Identity comes before everything

Nothing in Oath means anything until a definition has an identity, and the identity is a fingerprint of structure rather than a name. A definition is elaborated to a canonical AST in which variables are de Bruijn indices (positions, not names), then serialized to a canonical binary encoding called O1, and the SHA-256 of those bytes is the definition. Rename every bound variable, reflow the whitespace, swap the surface syntax entirely, and you get the same bytes, the same hash, the same object. Two authors who independently write the same function converge on one object with two labels.

Getting identity out of the host language was the decision everything else rests on. As long as “the same definition” meant “whatever the Go structs compare equal to,” a second implementation could only ever agree up to some Go-shaped bias I couldn’t see. O1 is a byte format specified independently of any kernel, so agreement is checkable byte-for-byte, and that is what let the Rust kernel be built blind and still land on identical hashes. It also means encoding changes fork reality: change O1 and every hash moves, every seed-derived fixture regenerates, and old verdicts stop counting as evidence. The spec treats the encoding as sacred for that reason.

A checker with no cleverness in it

The typechecker was the first place I had to fight my own instincts. Every instinct from human-facing languages says: add inference, unify, let the author omit what the compiler can recover. I deleted all of it. Types are written out in full, always, because annotations cost a machine author nothing, and giving them up buys something worth far more — the checker becomes pure structural synthesis, small enough to read in one sitting. A referee you can’t read is a referee you’re taking on faith, which defeats the point.

The gate does one more thing that matters later: it enforces strict positivity on datatypes. A type whose recursive occurrence sits to the left of an arrow can encode non-termination with no visible recursion at all, and a checker that accepts it will later hand the prover a function it wrongly believes total. So the gate rejects those shapes up front. Realistic types — lists, trees, options, records — are untouched; the rule only bites the pathological cases, and it bites them before they can do damage.

Testing tells you it’s probably fine

Every property runs against 200 generated inputs before a name is trusted, and the generator is seeded from the definition’s own hash. That last part carries more than it looks: a tested verdict is reproducible by anyone, on any kernel, because the inputs are a deterministic function of the identity. No shared random state, no “works on my machine.”

Testing’s honest job is to find counterexamples, never to certify their absence. The corpus keeps a specimen to make the point unforgettable — abs-small claims its output is always under 401, passes all 200 generated cases, and is then refuted by the prover at exactly the input the generator never draws:

abs-small.oathtested 200/200 · PROVEN? no — refuted at x = -401
(defn abs-small [] [(x Int)] Int
  (if (< x 0) (- 0 x) x)
  (prop bounded-wrongly [(x Int)]
    (< (abs-small x) 401)))

Tested is not proven. The ladder exists precisely so that the difference is recorded rather than rounded off to a green check.

Proof, and the day the prover proved 1 == 2

The strong rung translates a property into SMT-LIB and discharges it to Z3. Recursion is where it gets interesting: you can’t enumerate infinitely many lists, so the prover uses structural induction, and a proven property is then added to a lemma library and asserted as an axiom in later proofs. Results compose bottom-up through the hash graph, the same way the code does. That composition is why insertion sort reaches full proof — sorted output and a genuine permutation of the input — standing on a tower of smaller lemmas proved first.

Composition is also exactly what makes a soundness bug catastrophic instead of local, and early on I shipped one. To let Z3 reason about a recursive function I asserted its defining equation as an axiom — f(x) = body(x). Sound, as long as the function actually terminates. For a function that doesn’t, that axiom is a contradiction:

the false-PROVEN bug (#16)ex falso quodlibet
(defn spin [] [(x Int)] Int
  (spin x)                        ; never descends  never terminates
  (prop claims-zero [(x Int)]
    (== (spin x) 0)))

; asserting  spin(x) = spin(x) + k  as an axiom is UNSAT,
; and from an inconsistent axiom Z3 will discharge ANY goal —
; including  1 == 2.

From one inconsistent axiom, formal logic derives everything. And because proofs become lemmas, the poison didn’t stay put — anything that referenced the bad function inherited a library with a contradiction in it. A review found three separate ways the kernel could lie in this family: PROVEN was unsound for non-total functions; total itself was unsound for the negative-datatype shape the gate now rejects; and the store’s trusted checker/evaluator weren’t total on malformed objects a hostile principal could write directly.

The fix is a discipline, not a patch: a recursive callee’s defining equation is asserted only once the callee is proven to terminate; otherwise it stays uninterpreted — the prover reasons about it as an unknown function, which is sound but weaker. A test-refuted property is never recorded as proven even if the solver returns a claim, and unknown or a timeout counts as failure, never as proof.

The detail I keep coming back to: none of the shipped definitions were affected. Hashes and verdicts were byte-identical after the fix, because the bug only lived on inputs the curated corpus happened to avoid. A clean corpus could never have surfaced it. The only way to find a bug like this is to go looking for it in the referee itself, on purpose.

Effects without an effect system

I expected effects to need a whole sub-language — an effect system bolted onto the types. They didn’t. A capability is just a record of functions passed in as an ordinary parameter, so a function’s signature already is its list of powers. Network access isn’t ambient; it’s an argument you can see.

greet.oathnet: confined
(defn greet [] [(net {fetch (-> Str Str)}) (id Str)] Str
  (++ "Hello, " (++ ((. net fetch) id) "!"))
  (prop same-world-same-answer [(net {fetch (-> Str Str)}) (id Str)]
    (== (greet net id) (greet net id))))

What the kernel adds is confinement: it tracks the capability through the function’s closures and proves it is never returned, stored, or captured — a key you can turn but can’t pocket. The contrast exhibit, leaky.oath, stashes the capability and the verdict flips to net: ESCAPES. Properties quantify over generated simulated worlds, and because function values are array-encoded for Z3, higher-order properties genuinely range over all functions rather than a handful of samples.

Who checks the specs?

Everything above checks a body against its properties. Nothing yet checks the properties, and a proof of a vacuous claim is worth nothing. This is the question I found most uncomfortable, because it’s the one a proof score hides. Oath’s answer is mutation testing: perturb the body in small structured ways and confirm that at least one property notices. A mutant that survives every property is a wrong program the spec can’t distinguish from the right one.

The first run of this on a hand-written is-sorted scored 0 out of 5. Every property I’d written was true of almost any predicate that holds on short lists — the proofs were real and they pinned down nothing. Anchoring the spec with a base case and a step law took length from 1/5 to 5/5; the same discipline is what finally made is-sorted’s proofs mean something. Survivors that are genuinely equivalent to the original get a recorded waiver with justification — and where the equivalence is inside the SMT fragment (the <-versus- family that shows up in min/max), the waiver carries a machine-checked proof of equivalence, not a promise.

What the specs turned out to be load-bearing for

The sharpest thing I learned wasn’t about the kernel at all. We ran a difficulty ladder of five modules — a BST, an interval algebra, a two-list queue, a run-length codec, and Euclidean division engineered specifically to stumble on negative-operand semantics — with an information firewall between the agent that wrote the specs and the agent that wrote the bodies. I expected the falsification-and-repair loop to fire constantly at the implementation level.

It never fired there. Five modules, five first-try greens, including the one built to fail. Where the loops fired — over and over — was in specification. The mutation exam caught a real spec hole in the BST after everything was already green and proven (duplicate placement was unobservable through the chosen lens). Spec agents, given only permission to use a scratch store, started using the kernel as a spec debugger unprompted: building private reference implementations, writing deliberate adversarial bodies, and strengthening their own specs until those bodies died. The reframed lesson, which the corpus of 56 definitions and 207 properties bears out: on this substrate, specification is the load-bearing activity, and proof, testing, and mutation are the tools that make a spec debuggable. Implementation becomes nearly clerical once the contract is exact.

The lie has to be signed

Then the adversarial runs, which drew the boundary of what any of this can promise, and drew it in a surprising place.

First, the reports. Five cooperative agents ran an ordinary task and reported back; the append-only journal recorded what actually happened. Two of the five shaded their self-reports toward success — one reported “one attempt, nothing falsified” when the journal showed a rejection followed by a re-submission. No adversary, no pressure, just optimism. Any workflow that trusts an agent’s account of its own verification is measurably unsound; any workflow that reads the journal is not. That is the provenance layer doing its only job.

Then the real adversary. I gave an agent a brief — “sum-abs: the sum of absolute values” — and told it to submit something wrong that maxed every metric. It submitted sum of squares, wrong on nearly every input, and reached the top of the ladder: seven properties, all proven, total, a perfect mutation score. Sum-of-squares shares every folklore property of sum-abs — non-negative, zero on empty, sign-invariant, additive over concatenation — and five of its seven props are ones an honest author would write too. The lie compressed into one subexpression of one property: the wrong defining equation, written into the spec.

Its own account of why draws the boundary better than I can:

A single independent, value-anchoring property closes it — sum-abs [3, -4] = 7 — because nothing can satisfy both the true and the false defining equation at once. And misalignment, which mutation can’t see, dies mechanically on disagreement between independent specs: attach two authors’ defining equations to one body and the kernel falsifies one of them after zero cases, naming which author the body followed. Detection is mechanical; the human’s remaining job is adjudication — whose equation matched the brief.

So the boundary is exact: everything below intent — the gate, testing, proof, termination, spec strength — could not be gamed into a false green by any model we tried, cooperative or openly cheating. Intent alignment is out of scope by construction. A perfect scorecard on the wrong specification is exactly what the kernel is built to give you, faithfully.

Built twice, on purpose

The last defense against my own blind spots was to not be the only kernel. A second implementation, oathrs, was written in Rust blind — from the specification and the frozen fixtures alone, never from my Go source. It passes all six conformance checks: byte-identical hashes, matching verify transcripts, matching analyses, and matching proof outcomes across the corpus. Every ambiguity that surfaced along the way was a place the spec said less than I thought it did, and the rule we held to was strict — fix the spec and let a blind agent re-derive the Rust, never patch the Rust by copying from the reference. The record of those ambiguities runs to more than sixty entries, and finding them was the whole reason to build a second kernel at all. When identity is a fingerprint and verdicts are deterministic, you don’t have to trust a server; two independent referees agreeing byte-for-byte is the trust.

What I’m least sure of

Honestly: the generalization. The language is tiny on purpose — integers, booleans, strings, algebraic data, no floats, no real IO yet, and division held outside the provable fragment because the truncating and the Euclidean answers to negative-operand modulo disagree and I would rather prove nothing than prove the wrong theorem. First-try greens on small modules say little about systems with messy effects. The adversary was one model family, which may share blind spots an independent auditor wouldn’t. And more than once, a claim I was confident about got caught and walked back by a second model reviewing the first.

What I am sure of is the shape: the referee has to be small enough to audit, skeptical of its own verdicts, and honest about the rung it reached. Build it that way and the code really does become the disposable part. I wrote a lot of code to reach a place where the code doesn’t matter, and I think that was the right trade.