I planted a forbidden call in my core module, ran the build, watched it fail, and wrote down "determinism gate: active."
The file that defined that gate wasn't being loaded by anything.
This is a small Java 21 desktop clock (nene-clock, MIT) that I built in two days with an AI agent as a test bed for one rule: every meaning has exactly one implementation path, and a machine — not a reviewer — keeps it that way. Part of that rule is "core never reads the wall clock." I had wired forbidden-apis to reject Instant.now(), System.nanoTime() and friends by method signature, and I had a written proof that it worked.
What you'll get from this post:
- how a gate can fail for the right rule and the wrong reason, with the exact lines
- two more dead checks the same afternoon exposed, including a test suite that had never run
- the three habits I now apply before I write the word "active" next to anything
What broke
My repo keeps a file called gate-proofs.md. For every custom check, it records a planted violation, the task that ran, the output, and confirmation that removing the violation turns the build green again. The idea is simple: a check I have never seen fail is not a check.
The first entry, P1, was for the determinism rule. I put LocalDateTime.now() in :core:application, ran ./gradlew check, and it failed. I recorded the proof and moved on.
Before starting the next piece of work I tried a second probe, this time with two different APIs:
long probe = System.nanoTime() + java.time.Instant.now().toEpochMilli();
The build passed.
Why
The forbidden signatures live in config/forbiddenapis/determinism.txt. The convention plugin that every module applies was supposed to hand that file to forbidden-apis. Here is what it actually handed over, from the diff of the fix:
// build-logic/src/main/kotlin/neneclock.java-conventions.gradle.kts — before
forbiddenApis {
bundledSignatures = setOf("jdk-unsafe", "jdk-deprecated", "jdk-non-portable",
"jdk-internal", "jdk-reflection", "jdk-system-out")
signaturesFiles = files(rootProject.file("config/forbiddenapis/base.txt"))
}
Only base.txt. The determinism file existed, was committed, was referenced in the docs, and was read by nobody.
So why did P1 fail? Because forbidden-apis ships bundled signature sets, and jdk-unsafe forbids methods that silently use the default time zone. LocalDateTime.now() happens to be one of them. My probe had been caught by a rule about locale safety, not by my rule about determinism. System.nanoTime() and Instant.now() are not in that bundle, so they walked straight through.
It got slightly worse. The :adapters:system-time module — the one place allowed to read the clock — has an override that removes determinism.txt from its own signature list, so the exception is visible in the build file. That override was a no-op. You cannot remove a file from a list that never contained it.
The gate had failed for the right rule and the wrong reason, and the proof I wrote down was true and useless.
The other two things that were dead
Fixing the wiring meant rerunning everything, and rerunning everything found two more.
The conformance engine's own tests had never run. My project-specific checks live in build-logic, which is a Gradle included build. Tasks in an included build are not part of the root check unless you say so. I had written "every rule has positive and negative unit tests" — true — and neither ./gradlew check nor CI had executed them once. The first time I ran ./gradlew -p build-logic test, two failed. One of them meant that a correctly written waiver comment was being rejected, and the test for it was green because it only asserted the rule ID, not the reason the violation was reported. Failing for the wrong reason, again, one layer down.
The proof document's first version quoted output I expected, not output I got. When I retook P1 from the real console, the excerpt did not match what I had pasted. I had written the proof from memory of what the tool should say. That is not a proof; that is a wish with a code block around it.
The fix
PR #27 (+238 / −59), merged the same day as Issue #26 was opened:
- Build the signature list once and hand it to both the main and test forbidden-apis tasks. Tests reading real time are a determinism failure too.
- Make
checkdepend on:build-logic:test, so the engine's own negative proofs run on every gate. - Add a new rule, CNF-011: if a file under
config/is not referenced by any build script,validateConformancefails. "Placed but not wired" is now something the machine says, not something I have to remember to look for. - Retake every proof from real output and keep the wrong first version in the history.
The probe now fails like this — quoted from the console, not from my head:
Forbidden method invocation: java.lang.System#nanoTime() [現在時刻は WallClockPort / TickSource からのみ得る。
ここで読むと決定性が壊れる(ARC-007)]
Forbidden method invocation: java.time.Instant#now() [現在時刻は WallClockPort / TickSource からのみ得る。
ここで読むと決定性が壊れる(ARC-007)]
Scanned 17 class file(s) for forbidden API invocations (in 0.02s), 2 error(s).
(The messages are in Japanese because the repo is; they say "the current time comes only from WallClockPort / TickSource — reading it here breaks determinism (ARC-007).")
And CNF-011's own negative proof, P12, planting an orphan file:
CNF-011 config/forbiddenapis/orphan.txt
— この設定ファイルを読み込むビルドスクリプトが無い。置いても効かない
"No build script loads this configuration file. Placing it here has no effect."
The registry now has 14 proofs. Each one records what was planted, what task ran, what the tool printed, and that the build went green again after the violation was removed.
One thing I want to be precise about, because I got it wrong in a first draft elsewhere: this is not a story about Java being special. My Go repo enforces the same rule with depguard at the package level, and my Rust repo makes std::time a name-resolution error with #![no_std]. Java's forbidden-apis just names the ban at the finest grain — a method signature — which is what let me keep LocalDateTime.now(Clock) legal in the one adapter that needs it. The wiring mistake could have happened in any of them.
What I'd tell past me
A red build proves that something rejected your probe. It does not prove that your rule did. Read the message. Check the rule ID. If the tool that fired isn't the one you were testing, you have no proof yet.
A test that asserts only the rule ID will happily pass on the wrong rejection. Assert the reason, or at least assert on a fixture that can only fail one way.
Paste output; never type it. The moment I wrote what the tool "would" say, my proof document became documentation of my expectations. Those were exactly the expectations that were wrong.
And one for the toolchain rather than the person: presence of a config file is not evidence that anything reads it. If your gate has a config directory, write the check that fails when a file in it is orphaned. Mine is 53 lines plus a 46-line test, and it would have caught the original bug before I wrote a single proof.
Sources
- Issue #26 — determinism.txt is not loaded by any build: https://github.com/hideyukiMORI/nene-clock/issues/26
- PR #27 — the fix and the retaken proofs: https://github.com/hideyukiMORI/nene-clock/pull/27
-
gate-proofs.md§3 — the section titled "what we learned while proving", including the wrong first version: https://github.com/hideyukiMORI/nene-clock/blob/main/docs/quality/gate-proofs.md - The enforcement matrix, where this rule went from "active" back to honest: https://github.com/hideyukiMORI/nene-clock/blob/main/docs/QUALITY_GATES.md
Related posts from the same repo family: CI Paid for Itself on the First Run: 4 Repos, Real Bugs and My Generator Hid a Lint Error: CI Never Checks What Nobody Commits — both are the same shape: a green result that was measuring the wrong thing.
Which check in your build have you seen fail for the reason you think it fails for?
── Hideyuki Mori (Ayane International) 🔗 hideyuki-mori.com
Top comments (0)