DEV Community

Cover image for Sentinel Dev Diary: What the Specification Left Unsaid
Philip Shaw
Philip Shaw

Posted on Originally published at glitchedpixel.io

Sentinel Dev Diary: What the Specification Left Unsaid

Build step one is the semantic model: identity, the closed vocabularies, units, quality, retention class. It was marked complete a fortnight ago. Then it was audited against The Semantic Model, which is the document that owns it and which went out last Wednesday, and the audit is the part worth writing about.

Here is the thing I did not expect. The specification was not wrong anywhere. Not once. Every delta between what it says and what got built came from the same place: it described something carefully, at length, with the reasoning attached... and then never said whether it was optional.

Silence is not neutral. Silence resolves, and it resolves to whatever the code does by default.

Six rejections became twenty-eight

The exit criteria name six ways the registry must refuse bad input. app/registry/errors.py defines twenty-eight.

Most of that is not interesting. malformed_point_id, duplicate_point_id, canonical_unit_missing, kind_mapped_twice: these are finer-grained versions of what the criteria already asked for, and they need no justification beyond a load reporting what is wrong rather than that something is. If you are going to refuse, refuse specifically.

Two of the twenty-eight are different. They are new rules, in the sense that the specification does not state them.

A numeric point must declare a deadband (change_policy_required). The descriptor carries change_policy, and the document argues hard for why the deadband exists; without one "a noisy analogue sensor generates an edge per sample and the rules engine spends its life on 0.01 °C". Having made that argument, it never says the field is required.

So what happens if it is absent? Every sample becomes an edge. The exact failure the document spent a paragraph warning about, arrived at by leaving a field out. Booleans, enums and strings still default to any, because a boolean that changes is an edge by construction; but a float with no deadband is a mistake wearing the costume of a default.

A writable point must declare a confirmation mode (confirmation_mode_required). Same shape, worse consequence. There is no safe default here, and that is the whole argument: the mode an unset field would most naturally mean is optimistic, which is the mode that verifies nothing. A default that quietly means "assume the write worked" is not a default; it is a trap with good manners.

Both could have been solved with a default value. I want to be clear about why they were not, because "make it mandatory" is the more annoying choice and it needs defending.

A default is a decision made once, by whoever wrote the schema, on behalf of every point that will ever exist. It is invisible at the point of use. Nobody authoring a descriptor sees the deadband they did not write. Whereas a load-time refusal is a decision made by the person who knows what the point actually is, at the moment they are thinking about it, with a named error telling them precisely which of the twenty-eight things went wrong.

It costs a line of YAML per point. It buys the guarantee that no point in the system has a policy nobody chose.

Turning prose into types found a missing number

The Semantic Model writes its policies as strings: periodic(30s, grace=15s), absolute(0.2), poll_verify(500ms), assume(v). They are readable, they are compact, and they are exactly what Introduction warns against: "anything encoded as a string suffix will have to be parsed later."

So they became discriminated unions in app/schemas/descriptor.py. That is a straightforward application of a rule the specification already made, and it would not be worth a paragraph except for what fell out of it.

Writing the type forced a number the document never gives. periodic has a grace shown as optional, with no statement of what it defaults to. In prose that reads fine; you skim past it. In a typed model you cannot skim past it, because the field either has a default or it does not, and you have to type one or the other.

PeriodicFreshness.effective_grace now resolves it to half the interval, capped at 60 s, at load. Not at every read; the distinction matters, because a value computed at read time is a value that can differ between two readers of the same descriptor.

That is the second-order benefit of turning prose into types, and I had not really appreciated it before this step. The type system does not just enforce the document. It interrogates it, and it asks about precisely the things a careful reader glides over.

Three smaller decisions, one pattern

Units are nested under quantity rather than becoming a sixth vocabulary. The specification names five closed vocabularies and gives the unit table no home of its own, so it could have gone either way. It went inside quantity because the quantity owns its canonical unit and there is no useful reading in which the conversions into that unit live somewhere else. Nesting keeps the count at five as specified, makes unit_native convertibility a membership test rather than a lookup across files, and lets affine: false reject an offset at load; which, as the document itself points out, is where a 5 °C rise mistaken for 41 °F gets caught.

Derived fields are declared anyway, so authoring one is a named refusal. unit_canonical and interlock_protected are both derived. The document says so and says nothing about how a hand-authored one should fail. They are declared on PointDescriptor regardless, so writing one yourself fails as derived_field_authored rather than being swallowed as an unknown key. A typo and a misunderstanding deserve different errors; the first wants a spell-checker, the second wants an explanation.

dBm was kept rather than "corrected" to strict UCUM dB[mW]. The quantity table spells it dBm, which is not a valid UCUM code. It is, however, what every datasheet, vendor API and engineer writes for RSSI. The owning document was followed rather than quietly corrected in code, and the divergence was recorded in the register instead. This one is a small rule with a big blast radius: canonical values are frozen in the log at ingest, so changing that spelling stops being a YAML edit and becomes a migration over the highest-volume table in the system, the moment the first driver publishes.

The pattern in all three: where the document had made a choice, follow it and record the disagreement. Where the document had made no choice, make one and record the reasoning. Never quietly improve a document from inside the code.

What the step did not deliver

This is the half of an audit that earns it. Three findings, and the first two share a root cause: nothing compared registry version N against version N−1 at load.

A guard that existed as a function with a fixture

vocabulary_member_removed_while_referenced is one of the six exit-criteria rejections, and its fixture passed. Green. Ticked.

The check needs a baseline, and only the tests supplied one. Both real call sites passed none: the runtime at startup, and the CI check that runs against the actual registry. So removing heatsink from the subject vocabulary would have failed as unknown_vocabulary_member; a different error, from a different check, that happens to look exactly like the guard working.

That is the bit that took a moment to sit with. The guard was not missing. It was not broken. It was unarmed, and the symptom of it being unarmed was a passing test and a plausible-looking error message.

The fix was not "call it with a baseline". The fix was to make the resolver refuse when it cannot find a previous revision, rather than returning "no baseline" and carrying on. A fallback would have reproduced the original failure exactly where it matters most, which is a shallow CI checkout: no previous revision available, so no comparison, so no guard, so green. The cost is that CI now needs fetch-depth: 0, and the failure message says so in as many words.

A guard that degrades gracefully is not a guard. It is a guard-shaped hole that reports success.

The register had no row for addresses

The Semantic Model is the document that places addresses in the registry:

The registry holds addresses separately: MQTT topic, Modbus unit ID and register, BLE MAC, UniFi site path. These change freely without touching identity.

There is no field on PointDescriptor, no file under registry/, and no table. Nothing was built.

And that is defensible, because the document that consumes them is later in the series and the work is genuinely blocked behind it. What is not defensible is that the step's ledger had no row saying so. The gap between "deliberately deferred" and "forgotten" is invisible from outside; both look like an absence. The only thing that distinguishes them is a row somebody wrote at the time.

The transition criterion was delivered in half

The criterion asks that the quality transition table "exists as a checked enum, with a test asserting that no code path can produce a transition outside it".

The first half landed: the full 7×7 matrix is asserted, including the two an implementation gets wrong, which are that unavailable has only two exits and that nothing returns to a startup state. The second half could not land, because at that point nothing in the system wrote a quality at all, and the deferral was written into a docstring honestly.

The interesting part is what happened next. A later step added the first real writer, and a test that it cannot write an illegal transition. What was never added is the structural assertion that no writer bypasses the function; and the docstring still reads as though nothing writes a quality, which is no longer true. So the deferral note itself has rotted, in the specific way that deferral notes rot: not by being wrong when written, but by being right about a world that moved on.

The shape of the whole thing

One root cause produced three symptoms across two build steps, and none of the three looked related while I was in them. That is what an audit buys that a code review does not: not a closer look at a change, but a look at the step from the outside, against the document rather than against the commits.

The step was already marked complete when the audit ran. It stayed complete; nothing found here unmade it. What changed is that "complete" now means something checkable, because the enumeration of what is missing exists and is written down where it can be argued with.

I would rather ship a step with three named gaps than one with none I have looked for.


This Wednesday - The Driver Contract. The specification's third part: making every device look identical to the core while keeping the protocol ugliness on the far side of a wire boundary.

Next Monday - check in on how the next phase of development measures up.

Start of the diary: Four Registers and a Drift Check. Why the registers exist at all, and the one rule about where a sentence is allowed to live.

Top comments (0)