A published version number is a promise to whoever depends on you. The easy mistake is to treat it as a log of effort — every sprint earns a bump, every merge a new number on crates.io. But a dependency doesn’t care how hard you worked; it cares whether the bytes it links against changed. So gm-crypto-rs follows a rule: a version is a promise about output, and a cycle that changes no output doesn’t earn one.

That rule shows up as gaps in the public version line. crates.io has no 0.14.0. The work was real — a cargo-fuzz sweep, sixteen targets over the untrusted-input decode and decrypt surface, run to completion with zero crashes. It raised my confidence in the parser; it changed not a single output byte. Publishing it as 0.14.0 would have told every downstream “something moved, re-test, re-audit” when nothing they could observe had. So it merged as assurance and stayed unpublished.

The bigger gap is 0.17 through 0.23. That stretch was the pre-1.0 readiness work: freezing the public API behind a cargo-public-api drift-check, decoupling crypto-bigint from the always-on surface, a multi-model adversarial re-audit before committing to stability. Seven version numbers’ worth of the most consequential work in the project — and crates.io skips all of them. The changes shipped together, once, in 1.0.0.

The discipline underneath is easy to state and easy to violate: publish when the artifact someone consumes changes, not when you’ve been busy. Assurance work — fuzzing that finds nothing, an audit that confirms what you hoped, a test added for peace of mind — is the work you most want to point at. The temptation is to package it as a release so it shows up. But a release is a request: re-resolve, re-test, re-deploy. Asking for that when the bytes are identical spends your users’ attention on your bookkeeping.

1.0.0 is where it converged: all three crates published lockstep at =1.0.0, with the runtime wire output — SM2 signatures and ciphertexts, SM4 mode bytes — byte-identical to 0.16.0 and cross-checked against gmssl on eleven of eleven interop vectors. The breaking changes in 1.0 are API shape, not behavior; a 0.16 consumer gets the same bytes out. From there, cargo-semver-checks gates forward breaks, so the next number means what it says.

There’s a cost, and it mirrors the constant-time gate’s: a version line with deliberate holes needs explaining. A reader scanning crates.io sees 0.13, then 0.16, then 1.0, and has to be told the missing numbers aren’t abandonment — they’re cycles that refused to bill assurance churn as a byte-changing release. I’d rather owe that explanation than overstate a release. The full history, with the skipped cycles named and explained, is on the project page and in the public repo.