← All posts Engineering

Why we ship "unavailable" instead of guessing

Most analytics tools quietly treat missing data as zero. We made unavailable a first-class value instead. Here's the reasoning, and what it costs us.

sources resolve record refused

The most consequential design decision in Flolyt is one that makes the product look worse in demos.

When a metric depends on data we don't have — a disconnected source, a field that stopped populating, a schema that changed underneath us — we don't estimate it. We don't interpolate from last week. We don't fall back to a model. We display the word unavailable, and underneath it, the reason.

Prospects sometimes read this as a gap. It's the opposite. It's the feature the rest of the system depends on.

The failure mode we designed against

Missing data has a default behaviour in most analytics systems, and the default is zero.

Not because anyone chose it. Because SUM() over an empty set returns zero, a LEFT JOIN with no match produces nulls that get coalesced, and a dashboard that renders a bar of height zero looks exactly like a dashboard reporting a real value of zero.

So a webhook fails silently on a Thursday. Refund events stop flowing. Your refund rate drops to zero. Nothing on the screen distinguishes "we processed no refunds" from "we can no longer see refunds," and both render as good news.

The pattern generalizes badly:

  • A support integration expires. Ticket volume drops. Support looks like it's winning.
  • A field gets renamed in an upstream schema. Discount usage falls off. Margin looks like it improved.
  • A regional source disconnects. That region's leakage goes to zero. The map looks healthier than it was yesterday.

Causal finding this one is structural rather than statistical. In a system where absence and zero are represented identically, every silent data failure produces a favourable reading, because most of what you monitor is bad things you want less of. Broken pipelines look like progress. That asymmetry is not an edge case; it's the design's natural consequence.

What we built instead

Three things, and they only work together.

Absence is a distinct state in the type system. A metric in Flolyt is not a number. It resolves to either a value or an unavailability with a reason attached. There is no code path that turns the second into the first, because the type doesn't permit it. We learned early that a policy of "don't treat nulls as zero" enforced by convention gets violated within a quarter; it has to be impossible rather than discouraged.

Unavailability propagates. If a metric is unavailable, every metric computed from it is unavailable too, and each one carries the original reason forward. When contribution margin can't be computed because COGS stopped syncing, you don't see a wrong margin — you see unavailable, and the reason names COGS.

This is the part that took the longest to get right. A naive implementation makes the whole product go dark when one source hiccups. The propagation has to be precise about which metrics genuinely depend on the missing input and which don't.

The Data Quality agent watches for drift, not just outages. A source that stops sending is easy. A source that keeps sending while a field quietly changes meaning is the dangerous case — an enum that gains a value, a currency field that switches convention, a timestamp that changes timezone. When a field's distribution shifts beyond expectation, dependent metrics get suppressed and a Room opens rather than the number silently becoming wrong.

What it costs us

Every principle has a bill. Ours:

Demos are harder. A prospect connects one source in a trial and sees a screen with genuine values in some places and "unavailable — requires payments data" in others. Competitors show a fully populated dashboard because they're modelling the gaps. Ours looks incomplete. It is incomplete, which is the point, but that's a harder thing to say in a thirty-minute call than to write in a blog post.

Some customers find it pedantic. We've had people ask us to just estimate it. The honest answer is that we could, and that the estimate would be defensible, and that we'd then have no way to distinguish an estimate from a measurement six months later when someone makes a pricing decision on it.

It's more expensive to build. Every metric needs its dependency graph declared. Every agent needs to handle unavailable inputs without falling over. Every UI surface needs an unavailable state designed rather than a blank. That's a real, ongoing tax on velocity.

It occasionally makes us look worse than we are. When an upstream provider has an outage, we show unavailable. Tools that estimate through it show numbers. To a casual observer, they look more reliable during exactly the period when they're least reliable.

Where we do estimate — and how we label it

We're not absolutists about it. We estimate in two places, and both are labelled.

Confidence-scored identity resolution. Merging customer records across sources is inherently probabilistic. We auto-merge above 0.97 confidence, route 0.90–0.97 to human review, and never merge below 0.90. That's estimation, it's disclosed, and every merge is reversible.

Forecasts and scenarios. A forecast is explicitly a projection, presented as one, with its inputs visible. The distinction we care about is between a number that's presented as a measurement and one presented as a model output. Estimation isn't the problem. Estimation wearing a measurement's clothes is.

Why this matters more for an AI product

An AI agent that reads a zero and a real zero identically will build a case on it.

That's the actual stake. Our agents open Rooms, assemble evidence, and propose actions. If the underlying substrate can silently hand them fabricated zeros, then somewhere in a Room there is a confident, well-argued, fully-cited recommendation resting on a broken webhook — and a human will approve it, because it looks exactly like every good recommendation they've approved before.

You can't fix that with a better model. It has to be fixed underneath, in what the data layer is permitted to return.

Agents in Flolyt receive unavailability as a first-class input, and they're expected to say so. "I can't assess discount impact because COGS has been unavailable since the 3rd" is a valid and useful agent output. We'd rather ship that than a confident answer built on a gap.

What we'd change if we're wrong

The strongest argument against this is that it pushes cognitive load onto the user. A team that sees "unavailable" has to go understand why, fix an integration, and come back. A team that sees an estimate gets on with their day.

If we find that customers systematically ignore unavailable states rather than resolving them, then we've built something rigorous that doesn't change behaviour, which is just a different kind of failure. We watch the resolution rate on data-health Rooms for exactly this reason. So far it holds up — but that's an empirical question and we'd rather be caught by our own instrumentation than by a customer.