DEV Community

Nasrul Hazim
Nasrul Hazim

Posted on

Dev Log: 2 September 2026 — Every Bug Today Was a Claim Nobody Checked

Twenty-two commits across four repos today. Different stacks, different problems, and — reading them back in one sitting — the same bug wearing four costumes.

Something asserted a state. Nothing verified it. The assertion was wrong, and it had been wrong for a while.

A compliance control was green on evidence that didn't exist. A kitchen screen said there was nothing to cook while paid orders were sitting there. A dashboard reported a 97.8% success rate directly above thirteen failed rows. A service worker claimed to be serving today's CSS and was serving the first CSS it ever saw.

None of these threw. That's the shared trait. A stack trace is a gift — it tells you exactly where to look. A confident wrong number tells you nothing, and it keeps telling you nothing until someone counts by hand.


The control that was green on nothing

Start with the worst one, because it's the cleanest illustration.

A compliance reporter generates control evidence automatically — for each control, a line saying why the system considers it satisfied. One data-minimisation control cited two mechanisms: encrypted casts on the sensitive columns, and a redaction trait applied to the audited models.

The first half was true. The second half was not. The trait existed. It was applied to no model at all. The control had been permanently green on a citation that pointed at nothing.

And it wasn't simply forgotten. As written it could not be applied to the main user model — the audit package it needed to cooperate with declares a method of the same name, and two traits declaring the same method in one class is a fatal composition error in PHP:

class User extends Authenticatable
{
    use Auditable, RedactsPiiInAudit {
        RedactsPiiInAudit::transformAudit insteadof Auditable;
    }
}
Enter fullscreen mode Exit fullscreen mode

That insteadof is doing real work. Without it the class doesn't compile. Descendants of a base class that had one of the two were fine — a trait on the child beats a method inherited through the parent — so the conflict only appeared on the one model that mattered.

Two things I'd generalise from this:

Generated evidence needs a test that the evidence is reachable. A reporter that emits evidence_source: "...trait X" should be able to answer "which classes actually use X?" and fail when the answer is none. Otherwise the report is a string, and strings don't know if they're true.

Configuration on a trait belongs in a method, not a property. A trait and its consumer cannot both declare the same property with different defaults — also fatal. So:

trait RedactsPiiInAudit
{
    // Overridable. A property here would be a fatal clash the first time
    // a consumer wants its own list — and a consumer that forgets to
    // declare one would have no default at all.
    protected function redactedAuditAttributes(): array
    {
        return ['ic_number', 'phone', 'address'];
    }
}
Enter fullscreen mode Exit fullscreen mode

The bonus find, which is the part I'd actually lose sleep over: password hashes were being written into the audit trail. An audit table is immutable by design and retained for a year. A hash written there can't be withdrawn. Masking is the right treatment for PII that has to stay recognisable in an audit — a credential is never in that category, so credentials and 2FA secrets are now excluded outright, not masked.

The same commit deleted three classes referenced from nowhere. Static analysis had flagged two of them as used zero times, and someone had baselined the warning instead of deleting the files — which is its own version of today's theme. A baseline entry is a permanent, invisible ignore.

And a SECURITY.md that hand-mirrored composer.json had drifted in every single entry: it named a framework version two majors behind, and listed four dependencies the project doesn't have. A hand-maintained copy of a lockfile does not stay true. It now points at composer.json and names the audit commands instead. A stale security document is worse than no security document, because it looks like diligence.


Two screens that contradicted themselves

Same shape, two different products.

An audit dashboard rendered its summary cards from the controller using an unfiltered count, while the table below them came from the datatable's filtered query. Filter to "failed, last four days" and you got 1,949 attempts and a 97.8% success rate printed directly above thirteen failed rows. Both halves of one screen, disagreeing — and the reassuring half was the wrong one.

The fix is architectural, not arithmetic: the cards moved inside the Livewire component and are computed from the same baseQuery() that produces the rows.

// Whatever narrows the rows must narrow the summary. One source, two renders.
public function summary(): array
{
    $base = $this->baseQuery();

    return [
        'total'  => (clone $base)->count(),
        'failed' => (clone $base)->where('status', Status::Failed)->count(),
    ];
}
Enter fullscreen mode Exit fullscreen mode

If a number and a list on the same screen come from two different builders, they will eventually disagree. Not might — will.

The rebuilt report also added a period selector, because all-time was the only view and a large intake of successful records pinned the headline near 98% no matter what broke today. A metric with no time window is a metric that can't report a bad day.

Best change in the lot: a "why they failed" breakdown, grouping the recorded error messages and collapsing multi-line database stack traces to their error code. That data had been persisted from day one and never read once. The step tells you where something stopped; only the message tells you why.

A kitchen display screen in a point-of-sale app was the same bug in food form. The display queried for orders whose status was pending or confirmed. But the till takes payment first and cooks after — checkout creates the order, then processes payment, which sets the order to PAID while every line item is still PENDING.

So no order ever taken at the counter reached the kitchen. The screen had only ever shown "No pending orders", which reads exactly like a quiet afternoon.

The insight is a modelling one worth stealing:

Order status tracks money. Item status tracks cooking. A cook is looking at food, so the query has to key on items.

The query now excludes only orders that were killed — cancelled, voided — and keys everything else on item state. A ticket drops off the board once every item is READY.

Two statuses that both say "pending" are not the same status. Naming them for what they measure (PaymentStatus vs PreparationStatus) would have made the bug hard to write in the first place — which is most of the argument for enums over strings.


Caches that were confidently out of date

Two variations, both ending in "works after a hard refresh", which is the single most misleading symptom in web development because it makes the developer the only person who can't reproduce it.

A service worker was matching static assets by pathname alone. In development that meant it also intercepted the dev server's own CSS and JS on a different origin and cached them cache-first — permanently. Cmd+Shift+R bypasses the service worker entirely, which is precisely why only that showed the new styling.

Two fixes, and the second is the general one:

  1. Ignore every cross-origin request. Nothing from another origin belongs in an offline shell.
  2. Cache-first is only safe for content-hashed URLs. Anything at a stable path — /js/app.js, /icons/* — freezes at whatever shipped first, forever. Those moved to stale-while-revalidate: instant from cache, refreshed in the background. Bump the cache name so the frozen entries get evicted on activation.

The other variant: a generated report was styled by a bundle built before the report existed. Same family — a build artifact asserting it was current when it predated the thing it was styling.

While I was in there, a third one, and this is a nasty little Livewire trap:

Uncaught TypeError: $wire.view is not a function
Enter fullscreen mode Exit fullscreen mode

A list/grid toggle introduced a $view property on a component that already had a view() method. On the $wire proxy, a property shadows a method of the same name. PHP is happy. Blade is happy. Static analysis is happy. The only symptom is a click that silently stops working.

The guard is a test, because nothing else in the stack can see it:

it('has no property shadowing a method on any Livewire component', function () {
    foreach (livewire_components() as $class) {
        $properties = collect((new ReflectionClass($class))->getProperties())
            ->map->getName();

        $methods = collect((new ReflectionClass($class))->getMethods())
            ->map->getName();

        expect($properties->intersect($methods))
            ->toBeEmpty("{$class}: property shadows a method of the same name on \$wire");
    }
});
Enter fullscreen mode Exit fullscreen mode

Same day, same repo, a related guard: a missing icon component throws at render time, so only a test that opens that exact page catches it — and a page nobody covers stays broken until a user clicks it. The new test walks every Blade file for icon references and asserts the file exists. That's the right level. Don't test that page 47 renders; test that no page can reference an icon that isn't there.


The one where the bug was blamed outward

My favourite, because I've made this exact mistake and so has everyone reading this.

Four directory attributes came back null on live objects. This was written up as a missing read permission on the service account — a plausible story, an external cause, a ticket for someone else.

It wasn't. The profile screens in the same application display one of those very attributes, from the same directory, on the same bind, with no special grant. The evidence that the story was wrong was already on screen.

The actual defect: the helper normalises the needle to lowercase, but the attribute bag is keyed exactly as the directory returned it. A mixed-case name like pwdLastSet silently missed and returned null. The profile views never hit it because they iterate the raw bag and lowercase both sides of the comparison.

// Lowercasing one side of a comparison is not case-insensitive matching.
$value = collect($object->getAttributes())
    ->first(fn ($v, $k) => strtolower($k) === strtolower($needle));
Enter fullscreen mode Exit fullscreen mode

An earlier "fix" had added an explicit attribute selection — which only narrowed what came back, since an unrestricted query was already returning those attributes. That's the tell for a wrong diagnosis: the fix makes the system do less and the symptom stays.

The part that took the longest wasn't the code. It was going back to correct the audit finding that said the evidence was unobtainable without a permission change. It was obtainable the whole time. If the write-up survives and the diagnosis doesn't, the write-up is the thing people act on next year.


And the same idea, one layer up

The public repo today got a version of this on purpose. My Claude Toolkit shipped 2.5.0, and the substance of the release is that none of its 32 skills said when to stop — they were all "how to start".

Nine high-stakes skills now carry ## Common Rationalizations, ## Red Flags and ## Verification. The verification section is a checklist you run before claiming the work is done, and it's aimed squarely at today's failure mode:

  • [ ] The root cause is stated in one sentence, and it is a cause, not a symptom
  • [ ] Every caller of the changed function was checked, not just the reported path
  • [ ] A regression test exists, and it fails when the fix is reverted

That third box is the whole discipline in one line. Full write-up in the companion post.


Takeaway

The bugs that cost the most today weren't the ones that threw. They were the ones that answered confidently.

A stack trace is a system admitting it doesn't know. A green control, a 97.8%, an empty kitchen queue and a cached stylesheet are systems claiming they do. Only one of those two categories can be found by waiting for it to break.

So the pattern I'd take out of today:

Whenever code emits a claim — a compliance status, a summary count, a "no results", a cached response — ask what would fail if the claim were false. If the answer is "nothing", you don't have a feature. You have a decoration that people will make decisions from.

Every fix today shipped with a guard: a test that fails if the trait goes unused, a test that fails on a property/method collision, a test that fails on a missing icon, a query that can't disagree with the list beneath it. Not because the tests are impressive — most of them are ten lines — but because a claim that nothing can falsify will drift back the moment nobody's watching.

What's next: I want the compliance reporter to refuse to emit an evidence string it can't resolve to a live class. Right now it will happily cite a ghost.

Top comments (0)