{"slug": "i-stopped-storing-facts-and-started-storing-claims", "title": "I stopped storing facts and started storing claims", "summary": "A developer building software for residential construction has abandoned traditional fact storage in favor of a claims-based data model that records every source and evidence grade, preventing data loss and enabling conflict resolution. The approach scopes authority to domains and gates standoffs on evidence grade, with states including confirmed, reconciled, single-source, conflict, and unverified.", "body_md": "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.\n\n`bedrooms: 3`\n\n. Done.\n\nThat 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.\n\nThe usual fix is a priority order. Measured beats stated beats whatever the API returned. It works for about a week.\n\nHere is what I do instead, and the two design decisions that made it hold up.\n\nThe first change is small and it changes everything downstream. A row is not\n\n```\n{ bedrooms: 3 }\n```\n\nit is\n\n```\n{\n  field: \"bedrooms\",\n  value: 3,\n  source: \"assessor-record\",\n  evidence: \"RECORD\",\n  state: \"unverified\"\n}\n```\n\nThree 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.\n\nEvery claim carries an evidence grade:\n\n```\nMEASURED  >  STATED  >  RECORD  >  MODELED\n```\n\nMeasured 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.\n\nThis is the part I got wrong first, and it is the interesting part.\n\nIf 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.\n\nThe fix is to scope authority to a **domain** rather than to a source:\n\n| Domain | Authority | Because |\n|---|---|---|\n| Legal / valuation facts | Assessor record | It is the legal instrument |\n| The visible envelope | Vision pipeline | It is looking at the building |\n| Intent and recent work | Homeowner | Nobody else can know it |\n\nNow 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.\n\nOne 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.\n\nEvery field resolves to a state, and the states are the API:\n\n| State | Meaning |\n|---|---|\n`confirmed` |\nIndependent sources agree |\n`reconciled` |\nThey disagreed; resolved by domain authority + evidence |\n`single-source` |\nOnly one source. Recorded, and flagged as such |\n`conflict` |\nA genuine standoff. Surfaced, not hidden |\n`unverified` |\nNo verification stamp yet |\n\n`conflict`\n\nis 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.\n\nSuspect claims get **demoted, not deleted**. Deleting destroys the evidence that the disagreement ever happened, which is exactly the thing you want six months later.\n\nThe second decision is the one I would port into almost any system I write from now on.\n\nRecords can be signed — in my case by two parties, the homeowner and an internal reviewer. The naive version of this is a boolean:\n\n```\n{ verified: true, verifiedBy: \"...\", verifiedAt: \"...\" }\n```\n\nThat 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.\n\nSo the stamp is **bound to a hash of the content it signed**:\n\n```\n{\n  entryHash: \"a3f9...\",\n  verification: { by: \"homeowner\", at: \"...\", signedHash: \"a3f9...\" }\n}\n```\n\nChange the value, the hash changes, `signedHash !== entryHash`\n\n, 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.\n\nYou 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.\n\nIt also gives you a review queue for free, ordered by how much attention each thing needs:\n\n```\nquarantined  ›  lapsed  ›  unverified  ›  awaiting-stamp  ›  unstamped  ›  stamped\n```\n\nNothing goes unreviewed just because nobody happened to touch it.\n\nHonest accounting, because the whole point of the design is honest accounting.\n\nReads 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.\n\nWrites are chattier and the storage grows monotonically. You are keeping the losers.\n\nAnd 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.\n\nI 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`\n\nare fine and you should use them.\n\nThis is the Master Ledger inside [ML Systems](https://mlsystemsri.com), 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.\n\nI 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`\n\n, `MODELED`\n\nor `ASPIRATIONAL`\n\nfor exactly that reason, and I would rather tell you which is which than let you assume.\n\nThe 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](https://github.com/MLSystemsRI/ml-systems-public)\n\nIf 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.", "url": "https://wpnews.pro/news/i-stopped-storing-facts-and-started-storing-claims", "canonical_source": "https://dev.to/salparvez/i-stopped-storing-facts-and-started-storing-claims-17fd", "published_at": "2026-09-03 13:23:42+00:00", "updated_at": "2026-09-03 13:54:48.742487+00:00", "lang": "en", "topics": ["developer-tools"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/i-stopped-storing-facts-and-started-storing-claims", "markdown": "https://wpnews.pro/news/i-stopped-storing-facts-and-started-storing-claims.md", "text": "https://wpnews.pro/news/i-stopped-storing-facts-and-started-storing-claims.txt", "jsonld": "https://wpnews.pro/news/i-stopped-storing-facts-and-started-storing-claims.jsonld"}}