Fifty-three commits across eight repos today. Reading them back in one sitting, they sorted themselves into a single shape: something used a name as if it were an identity, and the name wasn't unique.
Two people sharing a username. Two accounts sharing an email. Sixteen environments sharing the word "Production". A class whose name got rewritten under a caller that looks it up by name. Nineteen URLs sharing one document. Different stacks, same bug.
Case-sensitive database, case-insensitive directory
The one that took the longest to explain, and it's four words: = is case-sensitive.
PostgreSQL and Oracle both compare strings case-sensitively. Active Directory, by specification, does not. So a uniqueness gate written as where('username', $input) will happily tell you AliceW is available while alicew exists — and when the downstream sync writes to the directory, both names resolve to the same object.
The gate says yes. The directory says "oh, that one". Two different answers to what everyone assumed was one question.
The fix is a small support class rather than a scope, because it's needed on both Eloquent and query builders in several places:
final class UsernameQuery
{
public static function whereMatches(Builder|QueryBuilder $query, string $column, ?string $username)
{
return $query->whereRaw(self::expression($query, $column), [self::normalize($username)]);
}
/**
* Two usernames collide when they differ only by case or surrounding
* whitespace — the comparison the directory makes, and the one the
* gates must make too.
*/
public static function matches(?string $a, ?string $b): bool
{
return self::normalize($a) === self::normalize($b) && self::normalize($a) !== '';
}
public static function normalize(?string $username): string
{
return mb_strtolower(trim((string) $username));
}
}
Three decisions in there are worth more than the code:
LOWER(col) = ?, not a case-insensitive LIKE. This is an exact-match gate. Usernames legitimately contain _, and LIKE treats _ as a single-character wildcard — you'd get false collisions on every underscore.
Assert on the generated SQL, not on the returned rows. SQLite folds ASCII case for =. A test that only checks "did I get the row back" passes before and after the fix when it runs on SQLite. The test that actually pins the behaviour asserts the query contains LOWER(.
it('lowercases both sides of the comparison', function () {
$sql = User::query()->tap(fn ($q) => UsernameQuery::whereMatches($q, 'username', 'AliceW'))->toSql();
expect($sql)->toContain('LOWER(');
});
Stopping new collisions surfaces none of the old ones. So there's now a sweep that answers the inverse question. Every other lookup in that system is subject-scoped — "who owns X?", and you need X up front. This one is an aggregate over the entire name space, which no existing tool could express.
Two details from writing it that generalise:
- The parts are combined with
UNION, notUNION ALL, because one person can hold the same name in two different columns. Without the dedup, every ordinary user is reported as colliding with themselves. - Any column with a stored prefix (
ext_and friends) has to have the prefix stripped for grouping, or that whole population silently contributes nothing to the sweep. Strip it withSUBSTR, notLIKE 'ext_%'—_is a wildcard, remember.
And then the finding I nearly shipped wrong: the first sweep flagged dozens of collisions as live takeovers. Almost none were. The risk label assumed a single directory namespace, and there isn't one — different identity types derive their directory object from different attributes entirely. Two owners can share a name without sharing an object.
Grouping now aggregates in two steps — per (name, type) first — so the outer query can distinguish "two of the same type share this" from "two different types do". The ordering keys on that signal too, because sorting on the raw count buried the one case worth acting on beneath thirty that weren't collisions at all.
A risk score that ranks noise above signal is worse than no score. You'll read the top of the list and stop.
A picker that collected a choice nobody consumed
Different system, same species.
A personal email address is not unique in most institutional databases — a person who progresses through two programmes keeps the same email on two ids. So the password reset flow shows a picker: here are your accounts, choose one.
The picker was correct. It rendered the accounts, it validated the selection, it carried it through the form. And then the resolver ran:
$row = IdmUser::whereEmailAlt($email)->first(); // by email. unordered.
The selection was collected and then ignored. Resets reported success, wrote to a stale id, and the live account was never touched — so the directory's pwdLastSet never moved and the client read it as "your reset isn't doing anything".
What makes this one worth writing down isn't the bug, it's the history. This was logged and closed a year ago. The fix that closed it shipped the picker — the UI half — and was marked Resolved. No test covered the multi-account path, so the incomplete fix shipped green and the gap survived twelve months behind a ticket marked done.
Four changes went in, and only the first is the fix:
// 1. Scope on the selected username AND the email, not the email alone.
UserCategory::whereUsernameAndEmailAlt($username, $email);
// 2. Backstop: abort and log before any backend write if the resolved
// identity doesn't match the one that was selected.
abort_unless(UsernameQuery::matches($resolved->username, $selected), 409);
The picker now labels each account with its directory status, so two bare ids are actually distinguishable to the human choosing between them.
The success screen names the account that was written. A bare "Success" is what hid this for a year — from the user and from support. If an operation picks one of several candidates, the confirmation has to say which one it picked. That's not UX polish; it's the only observability that costs nothing.
There's a diagnostics footnote too. The first report masked emails with one asterisk per character, so two unrelated people whose local parts shared their opening letters and length printed identically — the report read as one group where there were two. The counts were never wrong; the rendering was ambiguous. It now carries a stable group ref (first 8 hex of a sha256 over the lowercased address) and a fixed-width mask, because per-character masking leaks the local part's length for no benefit at all.
An identifier in a report needs to be unambiguous and stable across runs. A mask is neither.
"Confirm by typing the name" only works if the name is unique
Shipped a set of destructive MCP tools today — delete a project, an environment, a provider; retire a machine — and the guard pattern is the usual one: the caller must retype an identifier to confirm.
Except sixteen of the seventeen environments on the live control plane are called Production.
Retyping a name that sixteen records share confirms nothing an agent couldn't have guessed. So the confirmation is on the slug, and for a machine it's on the SSH host address rather than the hostname, which can be blank or shared.
The pattern, as a trait:
trait RequiresTypedConfirmation
{
protected function confirmOrFail(string $argument, string $expected, string $noun): void
{
// The argument key is passed in so a refusal is audited identically
// whichever tool refused.
}
}
Two other things went in the response text of every one of those tools, both because they're ways an agent reports a teardown wrongly:
- Deleting a provider removes the credential, not the host. An adopted machine keeps running and keeps costing money until its own hosting provider destroys it.
- The deletion is soft. "Deleted" in a tool response and "deleted" in a database are not the same claim, and an agent will relay the first as if it were the second.
If you're building MCP tools, that second one deserves a rule of its own: say what the tool did not do. An LLM will fill any silence with the most obvious inference, and for a delete tool the obvious inference is "the thing is gone".
There was also a dependency rule to move. The "can this be deleted?" check lived as a private method on a Livewire component, with a comment from its author warning it would be forgotten at the fifth call site. This was the fifth. It's now a RecordDeletionGuard service asked by both the UI and the tool — and extracting it exposed that the rule was also wrong: it counted every deployment row including destroyed ones, so the final step of a teardown was refused forever by the very deployments the teardown had just destroyed.
Related, same day: a "has history" rule that was blocking deletes. History is not a dependency. Every provider anyone ever bootstrapped accumulates job rows, so the rule read, in practice, "you may delete a provider only if you never used it". The rows are kept on purpose, the parent is soft-deleted anyway, and they still resolve through withTrashed(). Dropped.
A relation cached before the rows existed
This one is pure Laravel, and it's the sharpest edge I hit today.
A 24-step provisioning pipeline carries one Deployment model instance through every step in a context object. Step 11 reads $deployment->routingRules — as a property. That loads the relation and caches it on the instance. At step 11 it's empty, because the rules are written at step 16.
Steps 20 and 21 then read the same cached, empty collection, and took the "an empty set is an honest success" branch. So DNS and SSL both reported success on a deployment that finished 24/24 Active with no DNS record and no certificate. A second run worked, because it got a fresh instance.
// Cached at first access. Every later read on this instance sees step 11's answer.
$rules = $deployment->routingRules;
// Re-queries. In a long-lived pipeline this is the one you want.
$rules = $deployment->routingRules()->get();
Everyone knows relation access is cached. The part that bites is that it's cached on the instance, and in a request/response app instances don't live long enough for it to matter. Move that same model into a pipeline, a queued job chain, or a long-running worker and the lifetime changes underneath you without the code changing at all.
The general rule I'm taking from it: if a model outlives the thing that loaded its relations, treat property access as a snapshot, not a query. And be suspicious of any branch that reads "empty set → success". Empty is ambiguous. It means both "nothing to do" and "I looked at the wrong moment", and only one of those is a success.
A coverage test that could only see what today's fixtures happened to draw
Spent a chunk of the day on localisation — making English the key language, with the second language selectable rather than forced by an org-level setting.
I'd added a translation-coverage test the day before. It reported clean. Dozens of public-facing keys were untranslated.
The test was render-driven: render the pages, ask Laravel which keys were looked up and not found. Reasonable design. But the local seed had no priced membership tiers, so the tier cards never rendered, their keys were never looked up, and the test was satisfied.
A render-driven test is only as good as your fixtures. It can only fail on the paths your seed data happens to reach.
The fix is not to replace it but to put a second pass beside it:
-
Source-driven: every
__()key found in the public view tree must have a translation, whether or not anything renders it today. - Render-driven: kept, because it still catches strings emitted from outside that tree — a component library's own labels, for instance.
Neither alone is sufficient, and the two failure modes are exactly complementary.
Then a third one that neither pass can catch. Page titles were being set as literal view attributes:
<x-layouts.auth.card title="Apply for membership">
Never a key at all. The source scan looks for __(; the render pass needs a key to be looked up. A string that was never a key is invisible to both. Found it by opening the page.
Two lessons in one afternoon: automated coverage has a shape, and things outside that shape are not merely uncovered — they're reported as covered. And the most important page in a funnel is the one most worth loading manually before you trust a green test.
The build tool renamed a class the native layer looks up by name
The mobile side of the day. An app was dying mid-match — no Dart exception, no error screen, the process just vanished.
R8 (Android's shrinker/obfuscator) had renamed most of an ML runtime's Java classes down to two-letter names, the way it's supposed to. That runtime's native layer resolves those classes by their original names through JNI, so the first inference hit a null class in GetMethodID and the Android runtime aborted the process.
The rule R8 can't know: a class referenced only from native code has no Java-side reference to keep it, so as far as the shrinker is concerned nothing is using that name. Keeping it takes an explicit -keep — the same reason reflection needs one.
A crash with no stack trace in your language is almost always a layer boundary. JNI, FFI, a native plugin — something on the other side of a boundary that your exception handler doesn't cross.
And it's the name problem again, in the most literal form of the day: a tool rewrote a name, and something else was still holding the old one.
Sibling fix in the same release: two macOS crash reports were SIGSEGVs inside the bundled SQLite framework, and one of them showed the framework mapped twice — same UUID, two base addresses. Two independent copies of SQLite live in one process, each with its own global config, handles crossing between them. The database was being hosted on a spawned background isolate with handles sent across the boundary; opening it on one isolate removes the surface entirely.
Same name, two live things. Again.
And nineteen URLs sharing one document
The public one, and the reason it's the whole other post: devhub.my was serving the identical index.html for every path, at HTTP 200, because of the standard SPA catch-all rule. An SEO crawl reported five pages; the sitemap listed nineteen. Both were correct.
That's now a prerender step that writes a real HTML document per route and fails the build on three things no validator catches — a page that never declared its head, a JSON-LD graph with a dangling @id, and a canonical pointing somewhere the page isn't. Full write-up in the companion post; repo is developers-hub-my/website if you want to read the scripts.
Also, briefly
- A framework upgrade plus a design-system pass on one product: Laravel 13 / PHP 8.5, a starter kit dragged forward across thirty-odd minor versions, unified design tokens, and a shared list pattern rolled out screen by screen with one reference screen built first. Building the reference screen properly and then porting is slower on screen one and much faster on screens two through twelve.
- Stale published assets after a package update. If your framework has a "publish these vendor assets" step, it has a "these are now out of date and nobody noticed" state. Wire the republish into the update path rather than the install path.
- Migration planning as documentation, not as a ticket: what the current platform actually does, observed rather than assumed, before deciding what to replace it with. The probe that produced the observations got committed alongside the notes, which is the part I'd have skipped a year ago.
Takeaway
If today had a single rule in it, it's this: a name is not an identity. It's a label that happens to be unique in the sample you looked at.
Every bug above is what happens when code treats a label as a key — and every fix is the same move in a different dialect: normalise before comparing, confirm on something actually unique, name the thing you acted on in the response, and check the assumption at the layer that will be surprised, not the one that's convenient.
The cheap tell: any time you're about to compare, group, or confirm on a human-readable string, ask who else could hold that string. If the answer isn't "nobody, by construction", you have a bug waiting for a coincidence.
Top comments (0)