DEV Community

Salman Parvez
Salman Parvez

Posted on

I stopped storing facts and started storing claims

Every table I have ever written starts from the same quiet assumption: that there is one right answer and my job is to store it.

bedrooms: 3. Done.

That assumption survives right up until two sources tell you different things and both of them have a reason to be believed. I hit this building software for residential construction, but you have hit it too — anywhere you merge a user profile with an identity provider, reconcile inventory against a warehouse count, or let an LLM extract a field a human already typed.

The usual fix is a priority order. Measured beats stated beats whatever the API returned. It works for about a week.

Here is what I do instead, and the two design decisions that made it hold up.

Rows are claims, not facts

The first change is small and it changes everything downstream. A row is not

{ bedrooms: 3 }
Enter fullscreen mode Exit fullscreen mode

it is

{
  field: "bedrooms",
  value: 3,
  source: "assessor-record",
  evidence: "RECORD",
  state: "unverified"
}
Enter fullscreen mode Exit fullscreen mode

Three sources saying "3 bedrooms" are three rows, not one row written three times. Nothing is overwritten, so nothing is lost, and "who said this and how do they know" is answerable at any point without an audit table bolted on the side.

Every claim carries an evidence grade:

MEASURED  >  STATED  >  RECORD  >  MODELED
Enter fullscreen mode Exit fullscreen mode

Measured is something the system observed. Stated is a human asserting it. Record is an institutional file. Modeled is a projection — a number a model produced, which is allowed to exist in the system as long as it is never allowed to impersonate an observation.

A global priority order is wrong

This is the part I got wrong first, and it is the interesting part.

If you rank sources globally, a homeowner typing "it's a ranch" outranks the town assessor on the number of stories, because the homeowner is a human making a direct statement and the assessor is just a file. That is obviously nonsense. But the flat ordering has no way to express why it is nonsense.

The fix is to scope authority to a domain rather than to a source:

Domain Authority Because
Legal / valuation facts Assessor record It is the legal instrument
The visible envelope Vision pipeline It is looking at the building
Intent and recent work Homeowner Nobody else can know it

Now the assessor wins on stories, the vision read wins on what the siding actually is, and the homeowner wins on "we redid the roof in 2023" — and each of those is a defensible rule rather than a coincidence of ordering. Inside a domain, evidence grade breaks the tie.

One more rule that took a while to arrive at: a standoff is gated on evidence grade, not rank. A high-authority source with weak evidence does not automatically beat a low-authority source with strong evidence. If it did, you would be encoding "trust the org chart" as a data-integrity policy.

Reconciliation states, including one for "we don't know"

Every field resolves to a state, and the states are the API:

State Meaning
confirmed Independent sources agree
reconciled They disagreed; resolved by domain authority + evidence
single-source Only one source. Recorded, and flagged as such
conflict A genuine standoff. Surfaced, not hidden
unverified No verification stamp yet

conflict is the one that earns its keep. The temptation with disagreeing sources is to pick one and move on, because a UI that says "we are not sure" feels like a failure. It is not. Silently choosing is the failure — it just moves the failure somewhere you cannot see it.

Suspect claims get demoted, not deleted. Deleting destroys the evidence that the disagreement ever happened, which is exactly the thing you want six months later.

Signatures that lapse

The second decision is the one I would port into almost any system I write from now on.

Records can be signed — in my case by two parties, the homeowner and an internal reviewer. The naive version of this is a boolean:

{ verified: true, verifiedBy: "...", verifiedAt: "..." }
Enter fullscreen mode Exit fullscreen mode

That boolean is a lie the moment anyone edits the row. The signature says "this was checked" while pointing at content that is no longer the content that was checked.

So the stamp is bound to a hash of the content it signed:

{
  entryHash: "a3f9...",
  verification: { by: "homeowner", at: "...", signedHash: "a3f9..." }
}
Enter fullscreen mode Exit fullscreen mode

Change the value, the hash changes, signedHash !== entryHash, and the verification lapses automatically. Not "is flagged for review by a nightly job." Lapses, as a property of the data, at read time, for free.

You cannot quietly edit a verified claim and keep its stamp. That single property is the difference between a record that is auditable and a record that is merely editable, and it costs one extra column.

It also gives you a review queue for free, ordered by how much attention each thing needs:

quarantined  ›  lapsed  ›  unverified  ›  awaiting-stamp  ›  unstamped  ›  stamped
Enter fullscreen mode Exit fullscreen mode

Nothing goes unreviewed just because nobody happened to touch it.

What this cost

Honest accounting, because the whole point of the design is honest accounting.

Reads are more expensive. You are resolving a view over claims instead of selecting a row, so anything hot needs a materialized current-state projection, and now you have a cache invalidation problem you did not have before.

Writes are chattier and the storage grows monotonically. You are keeping the losers.

And the UI has to be able to say "these two disagree," which is a design problem most interfaces are not built to handle and which you will have to solve for real rather than hand-wave.

I think it is worth it in any domain where being wrong is expensive and where you will eventually have to explain how you arrived at a number. Where the cost of being wrong is low, a boolean and a last_updated are fine and you should use them.

Where this runs

This is the Master Ledger inside ML Systems, a construction technology company I run in Rhode Island. It is shipped and working — the ledger, the two-key verification, and the lapsing signatures are all live in the app on both app stores.

I should also be clear about what is not proven: the construction loop the ledger feeds is modeled, not measured. We label every claim in our public repo MEASURED, MODELED or ASPIRATIONAL for exactly that reason, and I would rather tell you which is which than let you assume.

The design docs are open, including the ledger, the ontology that governs how the claims compose, and how a whole house gets compressed into a canonical model: github.com/MLSystemsRI/ml-systems-public

If you have built something similar — especially if you found a cleaner way to express domain-scoped authority than a lookup table — I would genuinely like to hear it.

Top comments (0)