The Invariant Gate: Checks That Run, Prose That Gets Judged
Every codebase has rules that live in prose. Ours live in CLAUDE.md —
“no fallbacks,” “emit for, never while,” “a claim stamp stays honest” —
and until recently they were enforced the way prose rules always are: by a
reviewer who happened to be paying attention, or by nobody.
std/invariants already took the first step out of that arrangement: a rule
stops being a comment and becomes a declaration — a name, tags, the prose
itself, and an optional check: field, enumerable by koruc:
import std/invariants
std/invariants:inferred {
"name": "comment-language",
"tags": ["git-gate"],
"rule": "Comments are written in English, not Norwegian."
} The declaration is data in the AST, not a step in the flow — it costs the
program nothing and cannot execute. The disposition is carried by the name
itself: inferred means permanently reader-checked, so growth is healthy; aspirational means the rule intends to become executable, so growth is
debt. Nothing but the name distinguishes them, and nothing else needs to:
A gate is a consumer, not a subcommand
The listing command takes no arguments, and that refusal is the doctrine: koruc <file> invariants gate is rejected, because a gate is a consumer of
the listing — a script over enumerable rows — never a flag the compiler
knows about:
This week that consumer landed, at the commit boundary: invariants/gate.py,
wired into the pre-commit hook. It reads the listing, and each row chooses
its own execution strategy off the declaration itself — check: means a
deterministic checker exists and runs; its absence means the rule is
judgment-class and goes to a reader. The decidable get scripts; the
readable get a model.
The judgment class finally has an executor
All three deterministic invariants already carried check: fields — and two
of them were effectively orphaned: declared, executable, and run by nothing.
The prose-only invariant space was empty for the same reason: nothing could
execute a judgment-rule until koru/odds existed. A real judgment row looks
like this — the rule text written to be the question, diff-scoped and
verdict-shaped:
std/invariants:inferred {
"name": "no-fallbacks",
"tags": ["git-gate"],
"rule": "Does this diff introduce a fallback — a degraded path a reader
would mistake for the real surface working? A fallback is: a default
value substituted for a failed fetch or computation, placeholder
output standing where real content belongs, a swallowed error
converted into a silent skip or empty result, or a second code path
labeled 'for now', 'minimum', 'if X fails', or 'fallback'. Designed
states are NOT fallbacks: an empty state that says it is empty, a
documented default parameter, an idle pose, a named and tested
degraded mode. Answer yes ONLY if the diff adds or keeps a
substitute output that hides the real path's failure or absence."
} A second row, claim-stamps-stay-honest, covers std/rules: claim stamps
(proven, measured, inferred, aspirational) are inline in the diffed
source, so “did this diff weaken a declared claim silently?” is
diff-decidable — a flag for a claimed declaration edited away from its
evidence. A direct claims/assumes census consumer remains the deeper
version if the question proves too shallow.
The judge program is nine lines
The reader is a compiled Koru program — invariants/gate.k — that takes a
question and a state on argv and prints exactly one verdict line:
import koru/odds
import std/args
import std/io
std/args:get(index: 1)
| arg q |> std/args:get(index: 2)
| arg s |> koru/odds:judge(budget: 3): j |> koru/odds:feels(j, text: s, question: q)
| yes p |> std/io:print.ln("VIOLATION p={{ p:f }}")
| no p |> std/io:print.ln("CLEAN p={{ p:f }}")
| exhausted |> std/io:print.ln("UNJUDGED fuel exhausted")
| err e |> std/io:print.ln("UNJUDGED {{ e.msg:s }}")
| out-of-bounds |> std/io:print.ln("UNJUDGED missing state arg")
| out-of-bounds |> std/io:print.ln("UNJUDGED missing question arg") The whole judgment contract is the one that already exists: feels is the
noul — | yes f64 | no f64 — and the branch names are the answer space. judge issues a live judge against the model, and it is the only backend
the library ships: an earlier draft of the gate could swap in judge.mock, a deterministic hash wearing the same *Judge<greedy> type,
and we cut it — a mock in a library is a wart, and a hash verdict that
reads like a judgment is exactly the substitute surface the gate exists to
refuse. Offline, the honest answer is UNJUDGED, not a verdict-shaped
hash. Testing an odds consumer needs no library mock at all — the
language already has the seam: test() blocks substitute any invocation
with a branch constructor (koru/odds:feels => yes 0.9), so the judge’s
issue–ask–retire lifecycle runs real and only the network ask is scripted. UNJUDGED is the load-bearing verdict: no key, no fuel, no args —
the loud form of “the gate could not judge,” never a silent pass. The
runner treats it accordingly: advisory normally, blocking under GATE_BLOCK=1, because enforcement mode cannot let an unjudgeable row
slide.
Real output — the gate judging its own commit, the hook’s first production run:
check ok emit-for-not-while
check ok call-valued-index
judge ok no-fallbacks CLEAN p=0.97
judge ok claim-stamps-stay-honest CLEAN p=0.99
gate: 4 row(s), advisory — clean The same diff had read CLEAN p=0.98 minutes earlier at staging — live
judgments float slightly, which is exactly why the verdict carries a
probability rather than a boolean.
Two properties of the enforcing posture are worth stating plainly,
because they are the strangest true things in this post. You cannot
commit without an API key. Every commit in every repo that compiles
Koru requires a live judgment path — no key, no fuel, a dead endpoint
→ UNJUDGED → refused. That is either the point or the insanity, and
it is the same thing: the discipline costs a live model call per
commit, paid or blocked. And git commit --no-verify is part of the
design, not a loophole — Jev is a harsh judge, not an infallible
one, so the human override exists and is loud by its absence: a commit
that bypassed the gate lands with no verdict in its history at all. A
gate that could not be bypassed would teach people to remove it; a
gate that can be, teaches you when it was wrong.
The gate found work for the compiler
Wiring the gate meant making the declared checks actually pass — and emit-for-not-while was red: twenty flagged sites plus three stale
exemptions whose fingerprints still pointed at store.kz after part splits had moved the code. The check did its job twice over — it caught
both the drift and its own rotting exemptions.
Clearing it converted three counted while emissions to for: kernel’s
parallel-worker loops, io’s chunked writer, and supervisor’s bounded restart: N loop. The supervisor conversion surfaced two honest
constraints in the generated code. A for range bound must be usize, so
the emitted loop casts the budget — safe, because restart is enforced a
positive literal at the transform. And the loop must not fall through: the __left countdown guarantees a return by the final iteration, but Zig
cannot see that — so the emission ends in unreachable, which is not a
fallback but the assertion TigerStyle prescribes for a loop that cannot
legitimately exit:
var __left: i64 = 3;
var __arg_n = n;
for (0..@intCast(__left)) |_| {
const __r = main_module.worker_event.handler(.{ .n = __arg_n, });
switch (__r) {
.ok => |v| return .{ .ok = v },
.boom => |f| {
__left -= 1;
if (__left <= 0) { return .{ .boom = f }; }
__arg_n = f + 4;
},
}
}
unreachable; A reader who finds that unreachable firing has found a real bug — which is
the point of writing it down instead of letting the function’s end imply
one. The payload-argument supervision path that exercises this emission is
pinned by 320_154_supervised_transform_payload_args, green in the suite.
Rows can live in the files they guard
A git-gate row judges the whole staged diff. A git-gate-local row is colocated — declared inside the file it guards — and fires only when that
file is staged, judged against that file’s hunks alone. The scoping needs no
new syntax: the listing already prints each row’s loc, and a declaration’s
location names the file it guards for free.
std/invariants:inferred {
"name": "single-judgment-surface",
"tags": ["git-gate-local"],
"rule": "Does this diff add a second judgment implementation to
koru/odds — a deterministic, hash-derived, canned, or otherwise
offline path that emits verdict-shaped output without a live ask?
... Answer yes ONLY if the diff adds or restores a substitute
judgment path inside the package."
} That row lives inside koru-libs/odds/index.kz — in a different
repository. The gate resolves each local row’s file to its own repo and
reads that repo’s staged diff, so the odds package carries its own guard
against mock backends returning, and it fires on koru-libs commits. A local
row whose file is not staged did not fire: local skip, never UNJUDGED.
And the manifest guards itself — manifest-stays-enforceable asks whether
the diff weakens the declared discipline, judged exactly when the manifest
changes. Both rows measured live on their own commits: CLEAN p=1 and CLEAN p=0.99 on the diffs that added them.
What is still open
The honest residue. The posture flipped to enforcing early — the gate now
runs GATE_BLOCK=1 in the pre-commit of every repo that compiles Koru
(koru, koru-libs, orisha, koru-examples, korulang_org), which makes UNJUDGED blocking too: a dead judgment path stops a commit rather than
advising past it, and the key provisions from one canonical file outside
every repo. What that buys in discipline it pays in exposure — a
false-positive now halts work, so the hit rate gets measured in commits
refused, not advisories ignored. The model string floated with the judge
release — it now pins typesafe/jev-1.13-20260917, so “gated by Jev”
names which Jev. And the third question-source is
designed but unwritten: judging added comments for checkable claims —
prose that states a fact about the system a test or declaration could
carry — with aspirational and owed as the discharge path it routes
into.
What is not open is the shape. The interesting thing here was never “a model reviews commits” — it is that the rule corpus was already declared, enumerable, and dispositioned. Jev plugs in as the missing executor for the judgment class, and the same runner finally gave the deterministic checks a voice. The rules stopped being prose a reviewer had to remember. They are rows now, and rows get read.