DEV Community

Cover image for Dev Log: 6 September 2026 — A Guarantee You Only Wrote Down Isn't One
Nasrul Hazim
Nasrul Hazim

Posted on

Dev Log: 6 September 2026 — A Guarantee You Only Wrote Down Isn't One

92 commits today across six repositories, two products from scratch — a learning platform in Laravel and a puzzle game in Flutter. Very different stacks. Reading the day back in one sitting, almost every decision I made was the same decision:

Where do I put a fact so that it cannot become wrong?

Not "where is it convenient." Not "where will I remember to update it." Where can it not drift. Every good call today was some version of derive it, or enforce it — and the one call I got wrong was a guarantee I enforced on people who never asked for it.


1. The event stream that refuses to be updated

The learning platform records every analytic — funnel, item analysis, verification counts — into one append-only event table. The obvious way to build that is a table with a comment above it saying append only, please don't update.

I don't trust that comment. Neither should you. So the model has no updated_at, and it actively refuses:

public const UPDATED_AT = null;

/**
 * Refuse any mutation of recorded history.
 *
 * Enforced on the model rather than only by convention: an append-only
 * stream that can be quietly rewritten is just a table.
 */
protected static function booted(): void
{
    static::updating(function (): never {
        throw new LogicException('The event stream is append-only; a recorded event cannot be updated.');
    });

    static::deleting(function (): never {
        throw new LogicException('The event stream is append-only; a recorded event cannot be deleted.');
    });
}
Enter fullscreen mode Exit fullscreen mode

The : never return type is doing real work there — static analysis knows the closure can't fall through, so nothing downstream has to pretend an update might succeed.

An append-only stream that can be quietly rewritten is just a table. The word "guarantee" is doing no work unless something throws.

One nicety in the writer action: an unmapped event name records as unknown rather than throwing. Losing an event because someone typo'd the name is worse than an untidy label in a report. Strict about the shape, forgiving about the vocabulary.

2. The count that is never stored

Same system, immediately downstream. There's a public verification page, and I need to know how many times a credential has been verified.

Tempting: a verification_count column, incremented on each check. Fast reads, one integer.

It's also a number that can disagree with reality — a failed increment, a replayed request, a backfill that forgot, and now you have a count that nobody can reconcile against anything. So the count is a query over the event stream, always. The test that matters isn't "does the counter go up"; it's a replay test: rebuild the derived figure from the stream and assert it matches. That's the property that makes a single source of truth worth the join cost. A denormalised counter is a cache, and if you're going to keep a cache you have to be able to prove it's right — which means you needed the stream anyway.

3. A level is a name for a band, not a second number

Over on the puzzle game, the whole day's cleanest change. The game has a per-genre Glicko-2 rating — one number for "how good you are at this." Players don't read 1,732 as skill, so I wanted levels.

The lazy version: add a level field, bump it when the rating crosses a threshold. Now you have two numbers competing to mean the same thing, and they will disagree, and when they disagree neither one is trustworthy.

So nothing is stored:

/// The rating at which each level begins. Index 0 is level 1.
const List<int> kLevelThresholds = [0, 1100, 1350, 1600, 1850, 2100];

/// The level a rating sits in, counting from 1.
int levelFor(num rating) {
  var level = 1;
  for (var i = 1; i < kLevelThresholds.length; i++) {
    if (rating >= kLevelThresholds[i]) level = i + 1;
  }
  return level;
}
Enter fullscreen mode Exit fullscreen mode

Forty lines, no migration, no sync bug possible. Level and rating cannot disagree, because there is only one of them.

The genuinely useful part fell out sideways, and this is why I keep pushing on derived state. Each of the three puzzle genres had its own difficulty formula — its own magic constants, none of them agreeing on where a band started. Once "level" existed as a single derived idea, all three difficulty curves could be expressed in terms of it. "Level 4" went from being three formulas that happened to look similar, to being one idea. And now crossing a level is exactly the moment the puzzles get bigger, in every genre, which is a promise the UI can make honestly. A test pins that all three step at the same ratings.

I didn't set out to unify the difficulty curves. Naming the shared concept made the duplication visible.

4. The solve check that doesn't look at the answer

Second genre landed today — a nonogram, the grid puzzle where numbers along the edges tell you the runs of filled cells. Generating one is easy: make a pattern, derive the clues from it.

The trap is checking the solve. The obvious implementation compares the player's grid to the generated pattern. That's wrong, and it's wrong in a way that only shows up as an angry player: a clue set doesn't always describe a unique picture. Sometimes two different grids satisfy the same numbers. Rejecting a player's valid alternative because it isn't the one you happened to generate is a bug you'd never reproduce yourself.

So solveState compares clues to clues — derive the clues from what the player built, compare to the clues on screen, never look at the stored pattern. The test enumerates every grid the clues allow across 40 seeds and asserts all of them are accepted.

Same theme, third time: the pattern was a stored answer. The clues are the actual contract. Check against the contract.

Worth noting what didn't change: adding this second genre required zero edits to the engine core. The puzzle contract, the registry, the seeded PRNG, the rating maths, the attempt rules — all untouched. Shared code changed in two places: five lines in the composition root to register the engine, and a genre dropdown in the session screen that any second genre would need. That's the checkpoint I set the abstraction up for, and it's the only honest way to find out whether a one-implementation abstraction was real.

5. A parsed question, when it stopped being an array

Small refactor, unusually instructive. The learning platform imports question banks from Markdown — parse line by line, build up each question as you go.

The first version built each question as an associative array and mutated it as more lines arrived. It worked, then it started attaching options to the wrong question, silently, because the version before it juggled array references. Nothing in the type system was watching, and nothing in the reader's head was either — an array grown line by line has no shape you can hold.

Replacing it with a small object fixed the class of bug rather than the instance:

$questions[] = new ParsedQuestion(trim($m[1]), $outcome);
// ...
$questions[$last]->addOption(trim($m[2]), correct: isset($m[3]));
Enter fullscreen mode Exit fullscreen mode

Validation moved onto the object that owns it, so the "more than one correct option means multi-select, not a mistake" rule lives with the thing it describes instead of in a loop 40 lines below. And the static analyser can finally see the shape, which is the part that would have caught the original bug for free.

6. The one I got wrong

I required two-factor authentication for staff roles. It wasn't asked for. I did it because it seemed obviously correct, and it was the wrong call — you don't get to impose a security policy on somebody else's operational reality because it's good hygiene. It's now opt-in behind a config flag, off by default, and it switches on when the platform actually holds credentials worth protecting.

Turning it on for a day did earn its keep, though, because it exposed two dormant defects on the one page it herded people to:

  • The middleware redirected staff to the two-factor setup page — which the auth scaffolding guards behind password confirmation — and the middleware then bounced the confirmation screen straight back. ERR_TOO_MANY_REDIRECTS. My test checked only the first hop. First hop was correct. The chain wasn't. The test now follows redirects to the end and insists it terminates, and I verified it reproduced the loop before fixing it.
  • The scaffold shipped views calling an OTP component it never provided. Dormant for as long as nobody was forced onto that page. Requiring 2FA turned a missing component into a lockout with nothing else reachable.

Two lessons, and the redirect one is the sharper: asserting on the first response of a redirect chain proves almost nothing. A loop is exactly a sequence of individually correct hops.

The second is about blast radius. An error on a page nobody visits is a bug. The same error on the only page a blocked user can reach is a lockout. So enrolment is now tested end to end — enable, QR, confirm with a real TOTP — and there's a console command to enrol or reset an account from the CLI. That last one isn't a dev shortcut: an admin who loses their authenticator is locked out of every page including the one that would fix it, and on a one-person team there's no second admin to unlock them.

7. And the boundaries

I also drew eleven bounded contexts in the learning platform and made them executable the same afternoon — which got its own post — "Eleven Empty Folders and a Test" (companion post, link at review time) — because Pest's arch() helpers can't help you when the modules don't have any classes in them yet, and the workaround (read use statements out of the source text) has trade-offs worth spelling out.

Short version: an architectural rule that isn't a failing test is a preference.

Takeaway

One question, asked seven times today:

  • Can this fact be derived instead of stored? (level, verification count) → then store nothing.
  • Can this rule throw instead of being documented? (append-only, module boundaries) → then make it throw.
  • Am I checking against the answer or against the contract? (nonogram clues) → check the contract.
  • Does this data have a shape the analyser can see? (parsed question) → give it a class.
  • Did I test the first hop or the whole chain? (2FA loop) → the chain.
  • Is this guarantee mine to impose? (staff 2FA) → probably not; make it a flag.

The through-line: a fact stored in two places is a bug with a delay on it. Every one of today's good decisions was refusing to accept that delay, and the bad one was refusing somebody else a choice. Different mistake, same root — deciding on behalf of a future you can't see.

Top comments (0)