RockyGuard

Blog

The licence hasn't expired. The clock is three days fast.

·Rocky Software ·6 min read

engineering-notesdiagnostics

A licence reports LICENSE_EXPIRED. The support desk issues a replacement. The replacement reads as expired too.

This is the ticket that costs the most, and until v1.4 our own tooling could not answer it. The licence had not expired. The machine’s clock was running three days fast, and every licence that machine will ever be issued reads as expired the moment the clock skips forward again. Re-issuing does not fix a clock.

The information needed to say so was already in the report. It was just in two different places.

Why a per-section reader can’t get there

parse_health() walks a diagnostic report and emits one item per signal. It has been shipping since the Licence Inspector landed, and it is good at what it does: every item is a fact, drawn from one section, with no inference layered on top.

That is also its ceiling. The expiry verdict lives in the licence section. The measured clock drift that explains it lives in the clock section. A reader that derives every row from one section in isolation reports both — correctly, and as two independent findings — and leaves the join to whoever is reading. On a good day that is an engineer who spots it. On a normal day it is a support reply saying “we’ll send you a new licence.”

So the charter for diagnose() was narrow from the start: correlate across sections, never re-litigate inside a LicenseResult. A rule earns its place only by saying something a single error code cannot.

The rule that stops it becoming a second error reference

The obvious failure mode for a diagnostic engine is that it grows into a duplicate of the error catalog — same advice, different file, drifting apart at its own pace. Two years later nobody knows which one is authoritative.

One admission rule prevents it. A rule ships only if it does one of two things:

  • correlates two or more sections of the report, or
  • splits one code into distinct causes with genuinely distinct remedies.

The second case is narrower than it sounds, and the example that justifies it is HashMismatch. A binary that does not match its .sig is either a modified binary or a half-applied upgrade where the library was replaced and the signature was not. Those need opposite responses, and nothing in the code itself distinguishes them — only the file timestamps recorded in the report do. That split is worth a rule.

Where correlation changes nothing, no rule fires and the catalog’s own suggested_action is surfaced instead. The engine says less than it could, on purpose.

Ranking, and why there are no weights

Once you have several candidate causes you have to order them, and the tempting answer is a weighted score.

We rejected it. A weighted score is unauditable. When an engineer asks why the engine told their customer to renew a licence that was not expired, the answer has to be a sentence — and a weight vector is not a sentence. You cannot review one in a diff, and you cannot argue with one.

A free-form priority integer is worse in a different way. It rots. The first insertion is at 50, the next at 47, and within a year the numbers encode a history nobody remembers.

What shipped is a discrete key: (band, -confidence, declaration index). Band first, because a contributing factor must never outrank the thing it contributes to. Confidence second. Declaration order last — which means the tie-break is the order the rules are written in the source file, reviewable in a diff like anything else.

Suppression is a static per-rule list, applied only when the suppressor actually fired. It is one level deep and never transitive, and an Informational cause can never silence a Certain one.

Suppressed causes are kept, not deleted

When one cause explains another, the explained one stays in the result and is marked, rather than being dropped.

That costs a field and some rendering complexity, and it is not negotiable. An engine that silently discards what it rejected cannot be argued with by an engineer who thinks it is wrong — and in the field, that engineer will sometimes be right. Every cause carries the report fields it was drawn from, a confidence, one action, and a stable cause_id a program can branch on. The list is never empty, even for a healthy report.

It never reads the clock

diagnose() is a pure function of the report it is handed. It never reads the system clock. The only “now” anywhere in the implementation is clock.system_time_epoch — the value the report recorded on the machine where the problem happened.

That is what makes a diagnosis quotable. A report diagnosed a year after it was collected yields the byte-identical answer it would have given on the day. One std::time(nullptr) in a “days remaining” calculation would destroy the property silently, so the file that implements the rules includes no <ctime> at all and says so at the top.

The sign convention is the part that nearly went wrong. drift_seconds is defined as online_time - system_time, so a positive value means the clock is behind. A licence only appears falsely expired when the drift is negative. An early draft of the rule table had that inverted, which would have told customers with healthy licences to fix their clocks, and customers with genuinely dead licences that their clock was at fault. The test that covers it asserts both directions in one body, for the obvious reason.

There is a second guard worth naming. Before the engine will blame the clock, it checks that the licence is not also expired against the corrected time — otherwise “your clock is wrong” becomes a way to wave away a licence that really has run out.

What we deliberately did not ship

rg_inspect does not diagnose. The tool your end users run collects and seals; it does not interpret.

Two reasons, and neither is technical. The anti-rollback remedies double as a tamper recipe for whoever caused the problem in the first place. And changing what a customer’s end users see on screen is the customer’s decision, not ours. Causes carrying that kind of reasoning are flagged support_only, so a vendor building their own interface can filter them out and we are not the ones deciding for them.

The engine’s disclosure is also never broader than the report’s own, and narrower wherever the destination is a screen: no rule quotes a masked hardware value or a masked metadata value back, whatever the report was collected with.

On the reader side, --diagnose is a fourth mode. --summary is unchanged byte for byte, down to its trailing line, because its entire value is being identical to what the end user is reading aloud on the phone. Exit codes are unchanged too — 0 whenever the file was read, whatever it says. Diagnosing a broken licence is the tool succeeding, and a reader that exited non-zero on a successful diagnosis would break every script that tests for 0.


The full cause reference is Customer Documentation §10.1.2. If you want to watch it run against a real machine, the demo bundle is on the download page.