RockyGuard

Blog

Adding 56 diagnostic codes without breaking a single caller

·Rocky Software ·5 min read

engineering-notesapi-designabi

Deep in the integrity check, IntegrityReason knew whether a failure was a missing .sig file or a patched binary. In the time-anchor code, ClockReason knew whether a finding was an online-verified rollback or an ambiguous stale anchor. DecodeStatus knew whether an activation key was corrupt or merely from a newer format version.

All of it was flattened, at the return site, into one LicenseStatus and an English sentence.

A sentence is not a programmable surface. An integrator who wants to retry only the transient network failures, or to route “re-copy your activation key” to a different support queue than “your clock is wrong”, has exactly one option against a sentence: substring-match prose that we reserve the right to reword in any release.

Two prior fixes that improved the prose

This is not the first time the problem surfaced. It came up twice before, and both times it was fixed by writing better sentences — more specific wording, more detail about which check failed.

Both fixes made the distinction visible. Neither made it reachable. That is the whole story of this release note in two words, and it is worth naming because “improve the message” is the reflex answer and it is the wrong one every time the caller is a program.

Why LicenseStatus gained nothing

The obvious move is to add the missing cases to the enum that already exists. We did not add a single one.

LicenseStatus is small and exhaustively switched. The manual tells integrators to switch exhaustively, deliberately, so that a future addition surfaces as a compiler warning rather than a silent fall-through to a default branch. That is a good property. It also means every appended enumerator emits -Wswitch in every customer who followed the advice — a build-breaking diagnostic across the entire installed base, in exchange for a distinction that only newly written code can use.

Deleting or reordering would be worse. Status values get persisted — into logs, into databases, into a customer’s own support tooling — so their numeric meaning has to hold forever. NotYetValid sits mid-enum as a permanent reserved placeholder for exactly that reason: removing it would renumber every value after it.

So the new codes are a separate, orthogonal enum. LicenseErrorCode carries 56 enumerators, of which 44 describe failures; the other twelve are OK, UNSPECIFIED, and ten OK_* values for outcomes that pass but are worth reporting.

Orthogonal, not an extension

Every code maps onto exactly one status, and the mapping reproduces what v1.3.2 returned for the same input, byte for byte. Seven distinct INTEGRITY_* codes all still report IntegrityCheckFailed. Six NETWORK_* codes all still report ServerUnreachable.

That is the property that makes the upgrade a recompile rather than a project. Status-based code is untouched. Code that wants the finer answer reads LicenseResult::code; code that does not, never notices.

Structural, not careful

Two parallel values that must agree is a bug generator. The way to stop them drifting is not review discipline.

Every LicenseResult is now built by one internal factory that derives the status from the catalog entry for the code. Status and code disagreeing is not an expressible state — there is no return site that sets one without the other. ErrorCodeApiTest.StatusAlwaysAgreesWithCatalog walks the failure paths and fails if it ever becomes one.

There was a by-product worth mentioning, because it is the kind of thing a codebase accumulates without anyone deciding to. Routing every result through one factory revealed that the integrity branches had grown three separate phrasings of “re-install from the original distribution”, and the clock branches three phrasings of “set the clock and retry” — because each return site had written its own. Nobody chose that. It is just what happens when the message is assembled at the point of failure.

Numbering is an API decision

The values are explicit and block-allocated: 1000s for licence source, 1100s for keys, 1200s for signatures, 1400s for clock, 1600s for integrity, 1700s for network, 1800s for vendor licences. A new code takes the next free number in its block, so adding one never renumbers another.

The enumerator spellings break house style on purpose. SCREAMING_SNAKE_CASE is not decoration here — it is the wire name, returned by error_code_name() and accepted back. A code copied out of a customer’s log file greps straight to the table that defines it. The stated cost is that a name can never be corrected after it ships, because archived logs already contain it.

The ABI break, and the two alternatives we turned down

LicenseResult gained three members, appended at the end. The struct changed size. This is a MINOR release and the versioning policy sanctions appending across one — nothing was inserted or reordered, so status, message and grace_days_remaining keep their offsets — but a consumer has to recompile rather than drop the new shared library next to an old executable.

We considered two ways to avoid that:

A parallel last_error_code() accessor. Rejected because it puts the code somewhere the result is not. Every caller then has to remember to ask a second question, and the two answers can be read in the wrong order or from the wrong thread.

A pimpl’d result. Rejected because it turns a trivially copyable aggregate into a heap allocation, on the verification hot path, to avoid a recompile.

Appending won because its failure mode is honest. Get it wrong and you get a recompile; you do not get silent memory corruption.

Two details for the people who will notice

error_catalog() returns a pointer and a count rather than a std::vector. The symbol crosses the DLL boundary, and a standard-library container in that signature ties the caller’s C runtime to ours.

to_json() passes bytes at or above 0x80 through unescaped. A detail field carrying a filesystem path from a non-UTF-8 codepage is precisely where a strict encoder throws — and reporting an error must not be the thing that fails.


The migration is a recompile. Customer Documentation §10.1.1 is the code-keyed lookup and §3.5 is the recommended handling pattern. If you want to branch on a code yourself, the demo bundle is on the download page.