Offline canonicalisation, format validation and checksum validation of business identifiers, for the JVM and for Android.
The rules are not interpreted at runtime. A generator reads
spec/entid-rules.binpb when this library is built, runs the twenty-five
load checks of ir.md section 10, and emits Kotlin. What ships is that emitted
code, the primitives it calls, and a hand-written API — no ruleset, no Protobuf,
no decoder.
rules 2026.08.38, format version 1
94 identifier definitions · 37 kinds · 250 programs · 2386 IR nodes
conformance: 676 of 676 cases matched, 0 differed
| Step | What a valid answer means |
|---|---|
format |
The shape matches a variant the issuer documents. |
checksum |
The check digits the issuer publishes are satisfied. |
It says nothing about whether a company exists, is active, or owns the identifier. Answering that needs a register, and this version queries none.
Three statuses are not verdicts and must not be read as one:
unsupported— nothing here can conclude. No rule is published, no algorithm is documented, the country is unknown to this ruleset. Refusing a legitimate identifier is the most serious defect this project recognises, so absence of knowledge never becomes invalidity.not_run— an earlier step made this one pointless.invalid— a documented, applicable rule proved the value wrong.
The input is bounded at 1024 UTF-8 bytes before anything looks at it, and text
that is not well formed is refused as invalid_encoding. The bound is measured
first, and ill formed text has no UTF-8 encoding, so the count has to come from
somewhere. ir.md section 6 step 1 leaves an engine two answers and requires it
to state which it gives.
This engine counts what its own encoder produces. A Kotlin String holding
one unpaired surrogate — the only ill formed text the type admits — encodes to a
single replacement byte, so it counts as one. Utf.utf8Length therefore agrees
with String.toByteArray(UTF_8).size on every string, well formed or not, which
is stated as a property over generated input rather than left as a convention.
The array is never allocated; only its length is computed.
The other answer the specification allows is to refuse ill formed text before
measuring it. The difference is reachable only by text that is both ill formed
and within a few bytes of the bound, and both answers are unsupported.
Gradle:
dependencies {
implementation("org.entid:entid:0.1.0")
}Maven:
<dependency>
<groupId>org.entid</groupId>
<artifactId>entid</artifactId>
<version>0.1.0</version>
</dependency>The library declares nothing but the Kotlin standard library. A CI job fails the build if that stops being true.
import org.entid.EntIdEngine
import org.entid.IdentifierInput
import org.entid.IdentifierKind
val engine = EntIdEngine.default()
val report = engine.validate(IdentifierInput(IdentifierKind.SIRET, "012 345 674 00001"))
report.canonicalValue // "01234567400001"
report.isFullyValidated // trueEvery identifier in this file is synthetic, produced by the documented
generator of the shared conformance corpus, and named with the case it comes
from. 01234567400001 is siret-synthetic-valid-001. None of them designates a
company, and none should be treated as if it did.
val report = engine.validateFormat(IdentifierInput(IdentifierKind.SIRET, "01234567400001"))
report.format.status // VALID
report.checksum.status // NOT_RUN
report.checksum.reasonCode // NOT_REQUESTED// duns-valid-001, synthetic.
val report = engine.validate(IdentifierInput(IdentifierKind.DUNS, "012345678"))
report.format.status // VALID
report.checksum.status // UNSUPPORTED
report.checksum.reasonCode // CHECKSUM_NOT_PUBLISHED
report.isInvalid // false — unsupported is not a verdict// lei-canonicalize-020, synthetic.
val result = engine.canonicalize(IdentifierInput(IdentifierKind.LEI, "0000-0000-0000-0000-0098"))
result.canonicalValue // "00000000000000000098"
result.status // VALID// vat-gr-country-context-006, synthetic. Greece routes VAT under the prefix EL
// and the reported country stays the ISO code.
val report = engine.validate(IdentifierInput(IdentifierKind.VAT, "012345670", countryCode = "GR"))
report.canonicalValue // "EL012345670"
report.countryCode // "GR"A country that contradicts a recognised prefix is the one dispatch failure that is a verdict:
// dispatch-country-mismatch-004, synthetic.
val report = engine.validate(IdentifierInput(IdentifierKind.VAT, "BE0123456749", countryCode = "FR"))
report.format.reasonCode // COUNTRY_MISMATCHThe absence of a profile is meaningful: it is what lets the selected definition
apply its own default. Passing ValidationProfile.COMPATIBLE explicitly is a
different request from passing nothing.
engine.validate(input) // the definition decides
engine.validate(input, ValidationOptions(ValidationProfile.STRICT_CURRENT)) // current variants onlyimport org.entid.*;
EntIdEngine engine = EntIdEngine.defaultEngine();
ValidationReport report = engine.validate(IdentifierInput.of("siret", "01234567400001"));
report.getFormat().getStatus(); // VALID
report.getKindToken(); // "siret"Three things read differently from Java, and each has a reason:
EntIdEngine.defaultEngine()—defaultis a Java keyword, so the method Kotlin reads best is one Java cannot spell.IdentifierInput.of(kind, value)—IdentifierKindis a value class, which the specification requires so an unknown kind stays representable. Java sees the erasedString, and this factory is the entry point written for it.getKindToken()— for the same reason, the accessor returning anIdentifierKindcarries a mangled JVM name. This one returns the token.
| Minimum | Built and tested with | |
|---|---|---|
| Kotlin (consumer) | 2.1 | 2.4.10 compiles it, metadata 2.2 |
| JDK (consumer) | 11 | 17, 21 and 25 in CI |
| Android | minSdk 21, AGP 8.0 | AGP 9.3.1, compileSdk 36 |
| Gradle (consumer) | 8.0 | 9.7.0 |
The published bytecode is Java 11 (class file major version 55), and
BytecodeTest reads that back out of the jar rather than trusting the build
settings.
The Kotlin floor is worth a word. A Kotlin compiler reads metadata up to one minor version above its own, and the built-in Kotlin of the current Android Gradle plugin is 2.2. A library compiled at the newest Kotlin is therefore unusable on Android. This one is compiled by Kotlin 2.4 but speaks 2.2: language version, API version and core libraries alike.
Kotlin Multiplatform is not announced. This release targets the JVM and Android, and nothing else has been run against the conformance corpus.
Three rules check an identifier against a list a register publishes: 2 566 German court codes, 148 French greffe codes, and the Luxembourg section letter. Those lists are emitted as sorted string constants read by binary search, not as arrays walked from the front. A string literal lives in the class constant pool: no initialiser bytecode, no allocation before the first call, and a lookup that does not grow with the list.
That form was not a preference. Emitted one array literal per entry, 2 714 entries overflow the sixty-four kilobyte limit the JVM places on a class initialiser and the library stops compiling. Any array literal this generator emits is now split across methods once it passes five hundred elements, whatever a future ruleset carries.
The German list is split by length, and the emitted search reads one group per
length. 782 of the 818 six-character codes begin with a five-character one, so
over a single list starting with a code would not mean being one, and
DEB1000X would have passed on the strength of B1000.
There is no factory taking a ruleset as bytes. Such an API would put the
decoder, the twenty-five load checks and the whole execution machinery into
every caller — an interpreter, which engine.md section 1.2 forbids.
A custom ruleset goes through the generator, at build time:
./gradlew generateEngine \
-PentidBundle=/path/to/your/entid-rules.binpbor directly:
java -cp generator.jar org.entid.generator.MainKt \
--bundle your-rules.binpb \
--out src/main/kotlin/org/entid/generatedThe generator refuses to emit a single line from a ruleset that fails any of the twenty-five checks, and names the check that refused it.
| Path | Role |
|---|---|
entid/ |
The published library: emitted rules, primitives, API. |
generator/ |
Reads the ruleset, runs the load checks, emits Kotlin. Not published. |
testee/ |
The conformance testee. Not published. |
benchmarks/ |
JMH harness. Not published. |
consumer/jvm, consumer/android |
Builds of their own that take the published artefact. |
spec/ |
The pinned specification, schemas, ruleset and corpus. |
rules.lock |
The digests of every file above. |
The engine fetches the release; the release does not push into the engine.
.github/workflows/rules-sync.yml runs daily and on demand, compares the newest
spec release to rules.lock, and stops when they agree. When they differ it
downloads the artefacts into the runner's temporary directory, verifies
SHA256SUMS and then the provenance attestation — owner, repository, signing
workflow, tag, and the commit read from the signing certificate rather than from
the manifest the release itself wrote — and only then writes spec/,
rules.lock and spec/PROVENANCE.md, regenerates the emitted code, runs
./scripts/verify.sh and opens a pull request. A release whose attestation does
not verify never touches the working tree.
Two things follow from doing it this way. Regeneration needs this engine's
toolchain, which spec does not have, so the pull request carries the emitted
code and not only the bundle. And spec needs no write token here: the workflow
uses the GITHUB_TOKEN GitHub already gives it, so a compromise of spec stops
at spec. The trigger is a clock rather than a pushed event for the same
reason — a repository_dispatch would hand that token straight back.
The pull request is opened green or red. Green is the ordinary case: new business rules are data, and the emitted code follows. Red means the release brought something this engine does not do yet, and a red pull request is never merged to unblock the chain — it is corrected, or the release is refused with the reason written down.
The workflow asks for auto-merge on a green pull request, so a mechanical resynchronization needs nobody and only what stayed red wants attention. Three settings decide whether that is safe, and none of them can be granted by the token:
| Setting | Where | Why |
|---|---|---|
| Allow GitHub Actions to create and approve pull requests | Settings → Actions → General → Workflow permissions | Without it gh pr create refuses and step 6 cannot run at all. |
| Allow auto-merge | Settings → General → Pull Requests | Without it gh pr merge --auto refuses. |
A branch protection on main requiring Verify, and requiring nothing else |
Settings → Branches | Auto-merge merges when nothing blocks, which is not the same as on green. A second required check would give "green" two definitions, and auto-merge follows the weaker one — section 11.4 says so outright. The scheduled workflow and Dependabot must not be required checks. |
All three are on today.
Who publishes Verify on a synchronization pull request. Not ci.yml: a
pull request opened with a repository's own GITHUB_TOKEN starts no
pull_request workflow, because GitHub cuts there so an action cannot call
itself in a loop. A protection requiring a check that never starts would leave
every synchronization waiting for ever. The workflow has already run
./scripts/verify.sh on exactly that tree, so it publishes the result as a
commit status under the name the protection asks for. That needs statuses: write, which a repository has over itself — not a wider token, which is the
whole point of section 11.4.
This is also why Toolchain 25 is not a required check, and must not become
one. The far end of the JDK range is inside ./scripts/verify.sh, so the one
required status covers it. Requiring a second check would give "green" two
definitions and, since only Verify is published on a synchronization pull
request, would block every one of them for ever.
Tagging and publishing stay manual. Nothing in this workflow releases anything.
The library is published to Maven Central as org.entid:entid
— the Maven coordinates; the Kotlin package namespace is org.entid and
does not move.
A tag vx.y.z is the only thing that publishes.
.github/workflows/release.yml checks that the tag, EngineVersion.VALUE and
the changelog agree, runs the whole CI suite at that commit, builds a signed
bundle with ./scripts/release-bundle.sh, and uploads it to the Central Portal
as a USER_MANAGED deployment. It stops at VALIDATED; a person presses
Publish, having read what the Portal says it would publish. A tag can be deleted
and a deployment can be dropped — a version on Maven Central can only ever be
deprecated.
The upload is curl against the Portal's publisher API. Sonatype ships no Gradle
plugin for the Portal, and what does the upload runs with a PGP private key and a
publishing token in its environment, so it is four documented endpoints rather
than a release orchestrator or a plugin on the build script classpath.
CONTRIBUTING.md names the four repository secrets and the one namespace
verification a human has to do before any of it works.
The runner comes from the spec repository and from nowhere else, pinned to the
commit rules.lock records — the same commit as the corpus, which makes it
impossible to judge a corpus with another comparator. This repository provides a
testee and the tests that prove it does not cheat; it contains no comparator.
./gradlew :testee:installDist
GOTOOLCHAIN=auto go run \
"github.com/entid-org/spec/cmd/conformance-runner@$(grep '^source_commit' rules.lock | cut -d'"' -f2)" \
-corpus spec/entid-conformance.binpb \
-- ./testee/build/install/entid-testee/bin/entid-testeerules 2026.08.33: 676 cases, 676 matched, 0 differed
conformant
One command verifies everything, and it is the one CI runs:
./scripts/verify.shverify ok — rules 2026.08.38 · conformance 676/676 · tests 560 · toolchains 17+25 · coverage 99.04%/93.07% · jar 141566 B
It covers the lock digests, the regeneration of the emitted sources,
compilation, tests, the shared conformance suite against the runner from spec,
lint, format, coverage and its thresholds, the resolution of every declared
dependency, packaging including both consumer projects, and both ends of the
supported JDK range — the pinned toolchain throughout, and the far end compiled
and run again with -Pentid.toolchain. It prints that one line when
everything passes, the failing step's
output and only that when something does not, and exits non-zero either way it
should. engine.md section 12.6 asks for it; CLAUDE.md says why it is worth
preferring to the pieces.
The pieces are still there when a single one is what you want:
./scripts/verify-lock.sh # the eight digests rules.lock declares
./gradlew build # compile, lint, test, coverage, consumers
./gradlew generateEngine # re-emit the rules from the ruleset
./gradlew checkGenerated # fail when the committed sources are stale
./gradlew fuzz # Jazzer, beyond the regression corpus
./gradlew test -Pentid.toolchain=25 # the far end of the range, alone
./gradlew :benchmarks:jmh # the five measurements section 14 asks for, and moreCoverage is split the way engine.md section 12.2 splits it. The thresholds —
95 % of lines, 90 % of branches — cover hand-written code. The rules emitted
from the ruleset are reported beside it and never gated: that figure measures
the conformance corpus, not the engine, and a threshold there would fail an
irreproachable engine on a gap in the corpus.
hand written lines 99.04% branches 93.04%
emitted lines 88.58% branches 68.74%
The figures come from the test suite alone: the fuzz task is excluded from instrumentation, because a number that depended on whether Jazzer happened to run, and on which inputs it happened to generate, would not be a measurement.
./gradlew :entid:mutationTestPitest is aimed at the runtime primitives and the pipeline, where an off-by-one
in a comparison or a flipped bound is a wrong verdict rather than a compile
error. It scores 90.5 % — 856 of 946 mutants killed, against the 80 % that
engine.md section 12.5 recommends. The emitted rules are left out: mutating a
table produced from the ruleset measures the corpus again.
It found two real gaps, both now closed. The target index was compared with
< 0 where -1 is a sentinel and not an ordering, and a boundary slip survived
because the one dispatcher owning target 0 has a single target, so every
fallback converged on it; the comparisons are now == NO_TARGET. And neither
bound of the six code points a dispatch trim removes was exercised anywhere,
which TokensTest now pins on both sides.
The ninety survivors fall into three families, each equivalent by construction:
the fast-path early returns of CanonBuffer and Tokens, whose removal leaves
the value identical and only the work larger; the null-check intrinsics Kotlin
inserts, which are not this project's code; and the hash multiplier and a
StringBuilder capacity, neither of which any contract observes.
The engine is immutable after construction, safe to share between threads, and keeps no state between two calls. It performs no I/O, reads no resource, and makes no network call.
Every operation is synchronous, permanently. A remote register lookup, when it exists, will be a separate asynchronous operation in a separate module; it will never become a mode of these. This version exposes no registry type at all, not even an experimental one: a public type is a commitment SemVer freezes.
Apache License 2.0. See LICENSE.