<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Torben Labs</title>
    <subtitle>Torben — Senior Backend Engineer in Hamburg. Resilient distributed systems in .NET and Rust, and Tipperoo, an ad-free football prediction game.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://www.torbenlabs.com/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://www.torbenlabs.com"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-07-16T00:00:00+00:00</updated>
    <id>https://www.torbenlabs.com/atom.xml</id>
    <entry xml:lang="en">
        <title>Never score on unreconciled data</title>
        <published>2026-07-16T00:00:00+00:00</published>
        <updated>2026-07-16T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Torben
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://www.torbenlabs.com/posts/never-score-on-unreconciled-data/"/>
        <id>https://www.torbenlabs.com/posts/never-score-on-unreconciled-data/</id>
        
        <content type="html" xml:base="https://www.torbenlabs.com/posts/never-score-on-unreconciled-data/">&lt;p&gt;I’m building &lt;a href=&quot;https:&#x2F;&#x2F;www.torbenlabs.com&#x2F;projects&#x2F;tipperoo&#x2F;&quot;&gt;Tipperoo&lt;&#x2F;a&gt;, an ad-free football prediction
game, and I want to write about the part that surprised me.&lt;&#x2F;p&gt;
&lt;p&gt;I assumed the interesting work would be the scoring. It isn’t. Scoring is a pure
function: you have a tip, you have a result, you produce points. You can write it
in an afternoon and unit test it into the ground.&lt;&#x2F;p&gt;
&lt;p&gt;The hard part is the sentence just before that: &lt;em&gt;you have a result&lt;&#x2F;em&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;a-result-is-a-claim-not-a-fact&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#a-result-is-a-claim-not-a-fact&quot; aria-label=&quot;Anchor link for: a-result-is-a-claim-not-a-fact&quot;&gt;A result is a claim, not a fact&lt;&#x2F;a&gt;&lt;&#x2F;h2&gt;
&lt;p&gt;When a match ends 2:1, that number arrives from a data provider over HTTP. It
feels like a fact. It is not. It’s a claim, and claims have a lifecycle:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The match is still live, so the score is true &lt;em&gt;right now&lt;&#x2F;em&gt; but not final.&lt;&#x2F;li&gt;
&lt;li&gt;The match ended 90 minutes ago and the provider still says 2:1, which probably
means it’s final — or it means nobody’s updated it.&lt;&#x2F;li&gt;
&lt;li&gt;The match went to extra time, and “full time” means something different.&lt;&#x2F;li&gt;
&lt;li&gt;The result gets &lt;strong&gt;corrected&lt;&#x2F;strong&gt; an hour later, because an own goal was
reattributed.&lt;&#x2F;li&gt;
&lt;li&gt;The fixture was &lt;strong&gt;postponed&lt;&#x2F;strong&gt;, after people had already tipped it.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;If you treat the first number you see as truth, every one of those turns into a
user-visible lie. And in a prediction game, a wrong table isn’t a cosmetic bug.
The entire product is a promise that the standings are right. Get that wrong once
in front of someone’s colleagues and there’s no reason to come back.&lt;&#x2F;p&gt;
&lt;p&gt;So the rule I’ve been building around: &lt;strong&gt;never score on unreconciled data.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-that-looks-like-in-practice&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-that-looks-like-in-practice&quot; aria-label=&quot;Anchor link for: what-that-looks-like-in-practice&quot;&gt;What that looks like in practice&lt;&#x2F;a&gt;&lt;&#x2F;h2&gt;
&lt;p&gt;Three things, none of them clever, all of them annoying to skip.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Results have explicit states.&lt;&#x2F;strong&gt; A result is &lt;code&gt;provisional&lt;&#x2F;code&gt; until two independent
sources agree, and only then &lt;code&gt;confirmed&lt;&#x2F;code&gt;. Points computed from provisional data
are &lt;em&gt;shown&lt;&#x2F;em&gt; as provisional — visibly, with a label. Not greyed out and hoped
about. The UI is allowed to say “we don’t know yet”. It is not allowed to render
a confirmed-looking number over data that might move.&lt;&#x2F;p&gt;
&lt;p&gt;That distinction sounds obvious written down. It’s very easy to lose, because the
cheapest thing to build is a single &lt;code&gt;score&lt;&#x2F;code&gt; column that just gets &lt;code&gt;UPDATE&lt;&#x2F;code&gt;d, and
the difference between “provisional 2:1” and “confirmed 2:1” is invisible in that
model right up until it matters.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Results are an append-only log.&lt;&#x2F;strong&gt; A correction is a new event, not an update.
The standings are derived from the log, so fixing a result recomputes the table
instead of silently rewriting it — and I can answer “why did my points change?”
with something better than a shrug. This costs almost nothing to do on day one
and is genuinely painful to retrofit.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Live push carries no data.&lt;&#x2F;strong&gt; This is the one I changed my mind about. The
obvious design is to push the new score to the browser over a socket. It works,
and it’s fast, and it means the score on screen is now a claim your transport
layer is responsible for keeping true forever. Every dropped, duplicated or
out-of-order message becomes a wrong number in front of a user.&lt;&#x2F;p&gt;
&lt;p&gt;So instead the database emits an &lt;em&gt;opaque&lt;&#x2F;em&gt; signal — transactionally, as part of
the same write — and the client refetches the durable row. The signal carries no
score. It says only: something you care about changed, go look. A lost message
costs a refetch. It can’t cost correctness, because the message never contained
the truth in the first place.&lt;&#x2F;p&gt;
&lt;p&gt;That’s a worse design on a latency benchmark and a much better one at 5pm on a
Saturday.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-actual-lesson&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-actual-lesson&quot; aria-label=&quot;Anchor link for: the-actual-lesson&quot;&gt;The actual lesson&lt;&#x2F;a&gt;&lt;&#x2F;h2&gt;
&lt;p&gt;None of this is novel. It’s the same lesson every system with an external source
of truth teaches eventually: &lt;strong&gt;the boundary is where correctness goes to die.&lt;&#x2F;strong&gt;
The provider is not lying to you, it’s just answering a different question than
the one you’re asking. It’s answering “what do I currently believe?” and you’re
asking “what happened?”&lt;&#x2F;p&gt;
&lt;p&gt;The engineering is mostly in refusing to conflate those two, and in being willing
to show a user “not yet” instead of a confident wrong answer.&lt;&#x2F;p&gt;
&lt;p&gt;Ask me how well it held up after the first real matchday — the beta rides on 2.
Bundesliga in August, and I fully expect reality to find something I haven’t
thought of.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Tipperoo</title>
        <published>2026-07-16T00:00:00+00:00</published>
        <updated>2026-07-16T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Torben
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://www.torbenlabs.com/projects/tipperoo/"/>
        <id>https://www.torbenlabs.com/projects/tipperoo/</id>
        
        <content type="html" xml:base="https://www.torbenlabs.com/projects/tipperoo/">&lt;h2 id=&quot;why&quot;&gt;Why&lt;&#x2F;h2&gt;
&lt;p&gt;A &lt;em&gt;Tippspiel&lt;&#x2F;em&gt; is a fixture of German office and family life: everyone predicts
the scores, points accumulate, someone’s uncle wins. The category leader is a
decade-old site funded by advertising and bookmaker odds. That works, but it
means the thing you play with your family is also quietly a funnel into gambling.&lt;&#x2F;p&gt;
&lt;p&gt;Tipperoo is the counter-position: no ads, no odds, no entry fees, no operated
money — permanently, as a matter of identity rather than a launch promise.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-interesting-technically&quot;&gt;What’s interesting technically&lt;&#x2F;h2&gt;
&lt;p&gt;The hard part isn’t the tipping. It’s being &lt;strong&gt;right&lt;&#x2F;strong&gt;, in public, on a Saturday
afternoon when six matches finish at once and everyone is refreshing.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Never score on unreconciled data.&lt;&#x2F;strong&gt; A football result arrives from a provider
as a number, but it isn’t truth yet — it can be corrected, a match can go to
extra time or penalties, a fixture can be postponed after people have already
tipped. So results move through explicit &lt;code&gt;provisional → confirmed&lt;&#x2F;code&gt; states, and
points are only final once two independent sources agree. The UI has to &lt;em&gt;show&lt;&#x2F;em&gt;
that distinction rather than paper over it — provisional points are visibly
provisional.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Corrections have to be cheap.&lt;&#x2F;strong&gt; Results are an append-only event log. A
correction is a new event, not an &lt;code&gt;UPDATE&lt;&#x2F;code&gt;, so the standings recompute from the
log and the change is auditable rather than mysterious.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Live push that can’t lie.&lt;&#x2F;strong&gt; The tempting design is to push the score to the
browser. The problem is that a pushed score is a claim you then have to keep
consistent forever. Instead the database emits a transactional, &lt;em&gt;contentless&lt;&#x2F;em&gt;
signal, and the client refetches the durable row. A lost or duplicated message
costs a refetch — never a wrong number on screen.&lt;&#x2F;p&gt;
&lt;p&gt;Most of the interesting work has been in the seams: what counts as truth, how you
prove it, and what you show a user while you’re still deciding.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;status&quot;&gt;Status&lt;&#x2F;h2&gt;
&lt;p&gt;Not launched. The market page and waitlist are live at
&lt;a rel=&quot;noopener noreferrer external&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;tipperoo.de&quot;&gt;tipperoo.de&lt;&#x2F;a&gt;; the soft beta rides on 2. Bundesliga from
&lt;strong&gt;7 August 2026&lt;&#x2F;strong&gt;, with general availability targeted for &lt;strong&gt;18 August&lt;&#x2F;strong&gt;, gated on
readiness rather than the date. Bundesliga and Premier League for the 2026&#x2F;27
season, bilingual German and English from day one.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
