April 2026 was the largest month in this series, and the first draft of this article got it wrong in a way worth admitting up front. I wrote about the day job in detail and summarised twenty-two pull requests on my own Discord bot as a list of their titles. Then I went back and opened the diffs, and several of the things I had written turned out to be false — including a claim about a nine-thousand-line pull request that was mostly a lockfile. What follows is the version written from the code.
3cat-Sdn-Bhd/3cat 35 pull requests merged 349 contributions (private)
dansday-com/dansday-discord-bot 22 opened, 21 merged, 2 closed 395 contributions
dansday-com/dansday-main 2 pull requests 45 contributions
dansday-com/dansday-nocodb-enterprise created 24 April (private)
Seven hundred and eighty-nine contributions across four repositories. The bot produced more of them than the day job did — three hundred and ninety-five against three hundred and forty-nine — which is the fact that reorganised this article.
One number needs qualifying. GitHub counts forty-five commit contributions on dansday-main; twenty commits actually landed on the default branch, and sixteen of those twenty were made on a single evening between 17:09 and 19:27 on 10 April. I will come back to that evening, because it is the most concentrated piece of work in the month.
March's two reverts, done properly
Two of March's pull requests were reverted — one after two minutes, one after two days. April opened by redoing both.
The first, PDP, Checkout Quick Fixes, had been nine changed lines when it was reverted on 27 March. On 1 April it came back as 446 additions and 135 deletions across twenty-three files, with four follow-ups over the next week for an instalment calculation on the split payment page, a variant selector gap, and a font weight.
I find that jump instructive. The same ticket, the same title, and a fifty-fold difference in size. The nine-line version was me trying to satisfy a list of interface fixes with the smallest possible diff, which is usually a good instinct and was the wrong one here — the fixes were not independent, and touching one of them properly meant touching the layout they all shared.
The guard was reading the wrong field
The second revert is the one I wrote about at length last month, and April tells me exactly how wrong I was.
The problem was real: paying the balance on a deposit order reset its status to Ready to Process, wiping orders that had already advanced to stock transfer and losing the fulfilment team's visibility. My March fix guarded the promotion like this:
if ($order->status === 'pending_payment') {
That was reverted, which left the code back at its original state — no guard at all, a bare call that promoted every order whose payment completed, regardless of where it had already got to:
$this->orderRepository->updateOrderStatus($order, OrderModel::STATUS_READY_TO_PROCESS);
On 7 April it came back for the third time, as six lines:
$isPendingPayment = str_starts_with($order->sub_status ?? '', COreOrder::STATUS_PENDING_PAYMENT);
if ($isPendingPayment) {
$this->orderRepository->updateOrderStatus($order, OrderModel::STATUS_READY_TO_PROCESS);
}
The field is sub_status, not status. The value I needed was never in the column I was reading. So the March guard was not merely fragile — it was false essentially always, which means orders customers had fully paid for stopped advancing to fulfilment. That is precisely the failure mode I guessed at from the evidence last month, and it is worse than the bug I had set out to fix.
The corrected version also compares with str_starts_with rather than equality, because the sub-status is a prefixed value rather than an exact match — another thing I did not know about a field I was writing conditions against. And the same commit deleted an injected Request $request from the constructor: a dependency the class had been carrying without using.
There is a typo in my own fix. COreOrder, with a capital O in the middle. PHP resolves class names case-insensitively so it works perfectly, which is why it is still there. I made the same class of mistake in May 2024 with a lowercase promotion:: constant reference that PHP also tolerated and I also did not notice for days.
The back button, and what the fix actually turned out to be
On 8 April I shipped a pull request meant to preserve a customer's shipping choice across browser navigation. On 9 April I reverted it, then shipped a third version. My earlier draft of this article described that as a back-forward-cache problem fixed on the third attempt. Reading the three diffs, that is not what happened.
The 8 April change did three things, not one. It added a shipping-method restore inside the pageshow handler:
// event.persisted is true when page is loaded from cache (back/forward navigation)
if (event.persisted) {
this.syncCatCareFromSessionStorage();
this.syncShippingMethodFromSessionStorage();
}
It added a visibility toggle for the second payment field on split-payment carts. And it added a server-side rejection in storeOrder, returning 422 when a split payment was submitted against a free-reservation shipping method. The revert on 9 April took out all three, including the server-side validation, which had nothing to do with the browser.
The version that stuck moved the restore out of the back-forward-cache branch entirely and into the controller's ordinary initialisation, and deleted the fallback that had looked for whatever radio button happened to be marked checked in the markup:
syncShippingMethodFromSessionStorage() {
const savedShipping = JSON.parse(sessionStorage.getItem('selectedShipping') || '{}');
if (!savedShipping.method) return;
...
}
So the diagnosis changed between the second and third attempt, and I did not notice at the time that it had. The shipping method was not failing to survive the back button specifically; it was not being restored on any load, and the first fix had attached the restore to the one event where I happened to have observed the symptom. The fallback branch made it worse, because on a normal page load with nothing saved it would re-fire changeShippingMethod against the default option.
This is still the fifth separate month in which a browser back button has generated tickets for me. August 2024: returning from the payment gateway landed on a checkout whose cart had been deactivated, because the page wrote to the database in order to render. September 2024: the shipping option was not the one you picked. October 2024: the voucher was lost. June 2025: the phone number vanished. April 2026: the shipping method, and the honest version of the fix is that state restoration on this checkout has no owner and I keep discovering that one field at a time.
The same pull request also stripped the step-by-step comments out of the voucher re-application block — the // Step 1: Remove all vouchers from cart kind. Fewer lines, same behaviour.
Three attempts to find out why admins were logged out
The most interesting debugging of the month started from a two-word symptom: admin sessions going stale. People logged into the admin panel and were quietly logged out again. It took three pull requests over three days, and the shape of that sequence is the point.
26 April, 74 lines. Diagnostics added to the framework's authorisation middleware — every rejection path now logging the guard, route, session id and IP, with a helper that reports the session driver and probes Redis. The same pull request also deleted this from the secure-headers middleware:
$existingCacheControl = $response->headers->get('Cache-Control');
...
if ($existingCacheControl) {
$response->headers->set('Cache-Control', $existingCacheControl);
}
I had added that Cache-Control passthrough eleven days earlier, on 15 April, in a pair of pull requests titled Fixing secure header and Pass cache control. On 26 April, chasing a session bug, I took it back out. Two changes to the same six lines in the same month, in opposite directions, for two different reasons, and I cannot tell you from the record whether removing it helped.
27 April, 272 lines against five deletions. This is the largest thing I have ever written that produces no behaviour at all:
'session_cookie_present' => $request->cookies->has($sessionCookie),
'session_cookie_len' => strlen((string) $request->cookies->get($sessionCookie, '')),
'request_scheme' => $request->getScheme(),
'request_secure' => $request->isSecure(),
'request_ips' => $request->ips(),
'request_fingerprint' => $this->buildRequestFingerprint($request),
'auth_context' => $this->buildAuthDiagnostics($guard, $request),
'edge_context' => $this->buildEdgeDiagnostics($request),
Cookie presence and length, the scheme, whether the connection was secure, the chain of client addresses, a request fingerprint, and an edge context — because this storefront sits behind a CDN and there was already a middleware in the stack whose entire job is correcting the viewer's IP address at the edge.
The part that tells you what I suspected is the probe:
config('cache.prefix').':'.$sessionId,
config('cache.prefix').':laravel:'.$sessionId,
Two candidate keys for the same session, differing by one path segment, checked against the cache backend directly to find out whether the session existed at all and, if so, under which name. You only write that if you think the session is being written under one key and read under another.
28 April, 29 lines. A middleware called UseAdminSessionCookie, prepended to the stack immediately after the one that fixes the edge address:
if ($adminPrefix !== '' && ($requestPath === $adminPrefix || str_starts_with($requestPath, $adminPrefix.'/'))) {
$baseCookie = (string) config('session.cookie', 'laravel_session');
$adminCookie = str_ends_with($baseCookie, '_admin') ? $baseCookie : $baseCookie.'_admin';
config([
'session.cookie' => $adminCookie,
'session.path' => '/'.$adminPrefix,
]);
}
Admin paths get their own cookie name, suffixed _admin, scoped to the admin path. The storefront and the panel stop competing over the same piece of state.
I have written this exact fix before. The multi-country work in 2025 gave each country its own suffixed session cookie and cache prefix for the same reason: two things sharing one cookie name means whichever writes last wins. I knew the technique and still spent three days and 346 lines of instrumentation getting to it, because the symptom — "sessions go stale" — does not sound like a naming collision.
346 lines to find it, 29 to fix it. That ratio has come up before: October 2025's payment reconciliation was 452 lines of logging and a one-line database constraint. When two systems disagree and neither is obviously wrong, instrumentation is the work.
An AI reviewer on every pull request
On 9 April I merged 1,491 additions across twelve new files, and it deserves more than the one clause I originally gave it.
.github/workflows/gemini-dispatch.yml 221 lines
.github/workflows/gemini-scheduled-triage.yml 214
.github/commands/gemini-review.toml 172
.github/workflows/gemini-triage.yml 158
.github/workflows/gemini-plan-execute.yml 126
.github/workflows/gemini-invoke.yml 118
.github/commands/gemini-scheduled-triage.toml 116
.github/workflows/gemini-review.yml 109
.github/commands/gemini-plan-execute.toml 103
.github/commands/gemini-invoke.toml 97
.github/commands/gemini-triage.toml 54
An AI reviewer that comments on pull requests, an issue triager, a scheduled triage sweep, an invoke path for ad-hoc requests, and a plan-execute workflow that can propose changes. I should be clear that I did not write most of those lines: this is Google's published workflow set for their CLI, adopted more or less as shipped. Recognising that a template exists and wiring it into a specific repository's conventions is real work, but it is not 1,491 lines of my design and I am not going to present it as such.
What I did check is the parts that decide who can make it act, because these workflows hold write permission on issues and pull requests:
github.event.pull_request.head.repo.fork == false
...
startsWith(github.event.comment.body || ..., '@gemini-cli') &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || ...)
Pull requests from forks are excluded, so an outsider's branch cannot make the workflow run with repository credentials. Comment-triggered commands only fire for owners, members and collaborators. Every third-party action is pinned to a commit SHA with a # ratchet: comment naming the tag it corresponds to. Permissions are requested per job rather than granted at the workflow level, and the identity is a GitHub App token minted at run time with three scopes.
Those are the right properties, and none of them were my idea — they came with the template. It needed two follow-ups: a workflow fix on 26 April and a timeout increase on 30 April, because the review jobs were being killed before they finished on this repository's larger pull requests.
There is something to sit with in the ordering here. In the same month that I added a machine reviewer to the pipeline, the three most instructive bugs in this article — a guard reading the wrong column, a restore attached to the wrong event, a session cookie shared between two applications — were all found by reading logs and diffs by hand. I do not know yet whether the reviewer would have caught any of them. I have not gone back to check, which is the honest answer rather than a flattering one in either direction.
29,293 lines of lockfile, twice, in two days
On 9 April I merged a pull request called Fix Production packages: 29,293 additions across five files. It is a lockfile, committed so that production builds resolve the same dependency tree the development machine did. The next pull request, the same day, cached the Docker build layers so that having a lockfile actually buys something.
On 10 April, in a different repository, I did this to dansday-main:
17:09 Update .gitignore to include build directory and remove package-lock.json and composer.lock
17:14 Add composer.lock and package-lock.json files; update Dockerfiles to use npm ci
Five minutes. I removed the lockfiles from version control, and then re-added them along with the npm ci change that requires them. Twenty-four hours after learning the same lesson in a repository that takes real money.
Flash Sale, finally
On 13 April, Flash Sale on PDPs + Checkout merged: 1,429 additions, 377 deletions, twenty-eight files.
The same pull request, at exactly the same size, was opened and closed in March. Nothing about the code changed. It sat for three weeks and then went in, which is the most ordinary thing in this article and worth recording because these retrospectives systematically overrepresent drama. Most work is not reverted, argued over, or clever. It is written, it waits, and it merges.
Two days later, Better control caching for promotion — 265 additions, 280 deletions across fourteen files. A flash sale is a promotion with a hard start and end time, and this shop serves pages from a ten-minute edge cache that I put there in September 2025 specifically to stop maintaining an invalidation system. A sale that begins at noon and a page that may be ten minutes stale are a genuine conflict, and April was where I had to reconcile them. Note the shape of that diff: it removes almost as much as it adds. That is not a feature, it is a renegotiation.
Chat CTA 2.0
1,006 additions, 58 deletions, twenty-nine files, on the floating chat button.
I have written about that button more than any other single element on this site. January 2024: it overlapped the sticky product specifications bar. April 2025: an animation that bounced every half second forever, which I replaced the following month with something that fires once. May 2025 and June 2025: it covered slideouts and popups, twice. Its prefilled message has been rewritten at least four times, in the customer's voice rather than the shop's, with the product name interpolated, differently for out-of-stock, for instalments, for the exit-intent popup, and for the inspection video.
Version two is a thousand lines, which tells you the button had stopped being a button some time ago. It is the entry point to the conversation that closes most of this company's sales, and it had grown by accretion across twenty-eight months of individually reasonable tickets. This is the same story as March's promotion screen — three pages of configuration nobody designed — arriving in a different part of the codebase.
Also at the day job in April: auto-applying shipping-based vouchers on templated checkout links, a high-priority update to the XOX telco mechanism, deletable promotion frame icons, a nullable promotion icon, an SSL renewal on a secondary domain, a dependency version pinned after a bad resolution, and three warranty-portal changes — capturing consent in Supabase, a placeholder fix, and a refactor of how device IDs are handled during activation.
The bot stopped being a bot
Twenty-two pull requests, twenty-one merged, 395 commit contributions. More activity than the day job, and the reason is that April is the month this project acquired everything around the software.
A public landing page at the root of the panel, 322 lines, with a feature grid, an invite link, a community server link and a link to the source. A privacy policy and a terms of service, ninety and ninety-four lines, sharing a LegalDocPage component. A header. And featured servers on the front page, pulled from the ones that have opted into public leaderboards:
const base = slugifyDisplayName(s.name || 'server', 'server');
...
for (let i = 0; i < list.length; i++) {
all.push({ server: list[i], slug: formatIndexedSlug(base, i + 1) });
}
Two servers called the same thing get numbered slugs rather than colliding. That is a small thing, and it is the sort of small thing you only write once you expect strangers.
The same week brought a demo mode: 520 lines of seed data, a demo-login endpoint, an expiry listener, and an is_demo flag on the session, so that someone can look at the panel without installing a bot in their server first. And OpenTelemetry instrumentation, which is what you add when you have stopped being the only person whose experience of the software matters.
The interesting part is that none of this appears in the pull request titles. The demo mode arrived in one called Refactor leveling button components to streamline button creation. I will come back to that.
if (account_source === 'accounts') return true
The largest piece of real engineering on the bot in April was a pull request titled Better handling cache, and it is not about caching.
The panel's permission layer looked like this:
export function canEditServerSettings(locals, serverId): boolean {
if (!locals.user.authenticated) return false;
if (locals.user.account_source === 'accounts') return true;
...
}
Four functions — edit server settings, use the embed builder, view selfbots, manage selfbots — each with an unconditional return true for panel accounts. Any panel account could act on any server. That was fine for exactly as long as there was one panel account, which was me, and it stopped being fine the moment the panel had tenants.
So the pull request does two things at once. It inverts the ownership model in the database:
-- bots.account_id → bots.panel_id
UPDATE bots b INNER JOIN accounts a ON a.id = b.account_id
SET b.panel_id = a.panel_id WHERE b.panel_id IS NULL;
ALTER TABLE bots MODIFY COLUMN panel_id INT NOT NULL,
ADD CONSTRAINT fk_bots_panel_id FOREIGN KEY (panel_id) REFERENCES panels(id) ON DELETE CASCADE;
-- accounts.panel_id → panels.account_id
UPDATE panels p INNER JOIN accounts a ON a.panel_id = p.id
SET p.account_id = a.id WHERE p.account_id IS NULL;
An account no longer points at its panel; a panel points at its account, and bots hang off the panel rather than off the account. Ownership becomes a chain you can walk and check.
Then each return true becomes a question:
export async function accountOwnsServer(locals, serverId): Promise<boolean> {
const panelId = getPanelId(locals);
if (panelId == null) return false;
const serverPanelId = await db.getServerPanelId(serverId);
return serverPanelId === panelId;
}
Every one of those functions became async, because the answer now requires a database round trip. That change ripples through every caller in the codebase, which is most of why this pull request touches forty-nine files.
And the second half is a route guard table — eleven patterns, checked in order, first match wins, evaluated in the request hook before anything else runs:
{
pattern: /^\/api\/bots\/(\d+)(\/.*)?$/,
check: async (locals, match) => accountOwnsBot(locals, Number(match[1])),
superadminOnly: true
},
I like the table. One file now answers "who may call what", instead of the answer living in whichever endpoint you happen to be reading. The design has a cost I want to state plainly, because the same pull request paid it: it removed the in-route checks from the endpoints it now covers centrally. The quest-notifier test endpoint lost its canEditServerSettings call. The embed image upload lost its authentication check and its canUseEmbedBuilder call. And the table ends like this:
// No guard matched — allow through (unguarded route, handled by route itself)
return null;
An allowlist that defaults to allow, in a pull request that just deleted the fallbacks it defers to. Both halves are defensible in isolation. Together they mean that a new endpoint under a path nobody added a pattern for is open to any authenticated user, and nothing will tell me.
There are two smaller things in that file I would fix. The public-prefix list contains '/api/panel/', which makes the nine exact-match public paths beneath it dead code. And it contains '/api/bots/+server.ts' — a filesystem path in a list that is compared against URLs, so it can never match anything.
The escape hatch, used within a day
The guard table merged on 9 April. On 10 April I shipped two hotfixes against it, and the pair of them is the best short lesson in the month.
Hotfix: Fix forwarder guard issues, six lines:
const ROUTE_GUARDS: RouteGuard[] = [
+ {
+ pattern: /^\/api\/bots\/(\d+)\/servers(\/.*)?$/,
+ check: async () => true
+ },
{
pattern: /^\/api\/bots\/(\d+)(\/.*)?$/,
check: async (locals, match) => accountOwnsBot(locals, Number(match[1])),
superadminOnly: true
},
A pattern that matches everything under a bot's servers, answering true unconditionally, inserted above the ownership check so that first-match-wins short-circuits it. Something in the forwarder configuration screen needed access that the ownership rule denied, and rather than work out what, I put a hole in the table with the shape of the request. That is one day between designing a permission system and defeating it, and it is in the repository with my name on it.
The other hotfix, Enroll button missing permission, four changed lines, is the same problem from the opposite side. The quest enrolment button asked hasPermission(member, 'menu'), and 'menu' was missing from the list of recognised actions inside hasPermission — though it was present in the sibling function that reports which roles an action requires. The function ends in return false, so an unrecognised action denies everyone. The button was dead for every member of every server.
-if (['feedback', 'afk', 'leveling', 'giveaway', 'settings', 'staff_rating', 'notifications'].includes(action)) {
+if (['feedback', 'afk', 'leveling', 'giveaway', 'settings', 'staff_rating', 'notifications', 'menu', 'quest_enroll'].includes(action)) {
Two functions each carrying their own copy of the list of valid actions, drifting apart. The saving grace is the direction of the failure: unknown action means no, so the bug was a button nobody could press rather than a button everybody could. One hotfix failed closed and was embarrassing; the other failed open and I wrote it deliberately. I would rather have the first kind.
Only the server owner, and an invite by email
The pull request called Fixing panel restriction is the one my first draft got factually wrong. I wrote that it was "9,360 additions" of access control. It is not: 8,530 of those additions are package-lock.json. The actual change is about 830 lines, and it is better than the number I quoted implied.
The /setup command — the one that plants the bot's interface in a channel — used to be available to anyone holding a role the bot considered Admin. Now:
if (interaction.member.id !== interaction.guild.ownerId) {
await interaction.reply({ content: '❌ Only the server owner can use this command.', flags: 64 });
return;
}
Not a role, the actual Discord guild owner. And the command now takes an email address, because it is also how panel ownership begins:
const token = randomBytes(32).toString('hex');
await db.createServerAccountInvite({ token, server_id: server.id, account_type: 'owner' });
const inviteUrl = `${origin}/register?token=${token}`;
await sendServerOwnerInviteEmail(email, interaction.guild.name, inviteUrl);
The chain of trust is worth spelling out, because I think it is the neatest thing on the bot: Discord asserts who owns the guild, the owner runs one command in their own server, and that produces a ten-minute single-use link to an email address they typed themselves. There is no account-creation form anywhere on the site for server owners. You cannot ask for panel access; you can only be handed it by Discord's own answer to who runs the place.
The same pull request tightened the registration page. It used to render for any non-empty token:
const canRegister = !locals.user.authenticated && (('can_register' in locals.user && locals.user.can_register) || Boolean(token));
Now the page load fetches the invite and redirects unless it exists, is unused and is unexpired. I want to be precise about what that did and did not fix, because it would be easy to dress it up: the submit endpoint always validated the token properly — existence, used, expired, plus IP rate limiting and bcrypt at cost twelve — so no account could ever have been created from a made-up token. What the old code did was render a form that was guaranteed to fail. The fix is honesty in the interface, not a closed hole.
And it renamed a role. moderator became staff, which in MySQL means an ENUM migration in three movements — widen to accept both, update the rows, narrow to the new pair — each step guarded by a check on whether the old value is still in the column definition:
ALTER TABLE server_accounts MODIFY COLUMN account_type ENUM('owner','moderator','staff') NOT NULL;
UPDATE server_accounts SET account_type = 'staff' WHERE account_type = 'moderator';
ALTER TABLE server_accounts MODIFY COLUMN account_type ENUM('owner','staff') NOT NULL;
One thing in that pull request I would argue with myself about. It drops created_by and created_by_admin from the invites table, along with their foreign keys. Those columns recorded who issued an invitation. Removing them simplifies the code that creates invites — two fewer parameters, no branching on account source — and it deletes the only record of who let someone into a server's panel. In a change whose subject is access control, that is the wrong trade, and I made it for tidiness.
Six migrations for one table, and then no migrations at all
On 9 April I built a Roblox catalogue notifier: the bot watches the catalogue and posts an embed to a channel when an item appears, or when its price or stock changes. 327 lines of notifier, 170 of API client, and six migrations, all in the one pull request.
The six migrations are the story. The first creates the table with twenty-one columns. The third drops seven of them: item_type, collectible_item_id, creator_type, creator_target_id, is_official, is_limited, is_free. I had designed a schema by guessing what the API returns and what I would want, then deleted a third of it before the pull request was even merged, because by then I had seen the actual responses.
The fourth adds last_price and last_total_quantity to the per-server table, so the notifier can tell what changed. The fifth moves them to the per-bot table and drops them from the per-server one, because change detection is a property of the item in the world, not of one server's relationship to it. That is a modelling mistake made and corrected inside a single afternoon, and the fix is visible only because I ship migrations rather than edit them.
Every one of those migrations is idempotent by hand, because MySQL has no ADD COLUMN IF NOT EXISTS:
SET @col1 := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bot_roblox_items'
AND COLUMN_NAME = 'last_lowest_price');
SET @sql1 := IF(@col1 > 0, 'SELECT 1', 'ALTER TABLE bot_roblox_items ADD COLUMN last_lowest_price INT NULL');
PREPARE stmt1 FROM @sql1; EXECUTE stmt1; DEALLOCATE PREPARE stmt1;
Six lines of ceremony per column. It works and I do not enjoy it.
There is a practice underneath this that I should name rather than let pass. This project has no migration ledger. The SQL files are one-shot scripts, applied by hand and then deleted from the repository — that is what the pull request titled Stabilize bot feature actually is, twenty-two additions against 452 deletions, removing four migration files that had already been run. My first draft of this article called that "stability by removal" and read it as deleting logic. It is deleting history.
The consequence is visible in the numbering. April contains a 0001_bot_discord_quests.sql, and then a week later a 001_drop_server_account_invites_creator_columns.sql, and then a 001_member_notifications_channel.sql, and then a 001_add_analytics_tables.sql. Four different files called migration one, in one month. There is no way to ask this repository what shape the database is in, and no way for a second person to bring an environment up to date. It is fine while I am the only one deploying, and it is the first thing I would have to fix if that changed — which, given that this same month added a landing page and a terms of service, is a thing I have started assuming will change.
One more detail in that table, unresolved: the unique key is UNIQUE KEY (asset_id), while the table also carries a bot_id and an index on it. Global uniqueness on an item, in a table scoped per bot. If a second bot ever tracks an item the first bot already knows about, that insert collides. Today there is one bot, so it does not matter. It is written down here so that when it does matter I will not be surprised.
Sixty seconds, then four
The notifier polls. Over four days I changed how hard, and the trajectory is the thing I am least comfortable with in April.
Day one: poll every sixty seconds, ten seconds between catalogue pages, three attempts on failure with a thirty-then-sixty-second backoff, and then an exception. A polite client.
if ((status === 503 || status === 429) && attempt < 2) {
await new Promise((r) => setTimeout(r, 30_000 * (attempt + 1)));
continue;
}
throw new Error(`[roblox-api] failed ${status} on ${fullUrl} — ${body}`);
Day four, after a pull request titled Better retry for roblox api:
while (true) {
try {
...
} catch (err: unknown) {
logger.log(`⚠️ [roblox-api] catalog fetch failed ${status ?? '?'} — ${msg}, retrying in ${ROBLOX_CATALOG_POLL_MS / 1000}s...`);
await new Promise((r) => setTimeout(r, ROBLOX_CATALOG_POLL_MS));
}
}
No attempt limit, no backoff, no error surface. And the constants moved to config on their way down: the poll interval went from 60,000 milliseconds to 6,000 to 4,000, and the delay between pages from 10,000 to 3,000 to 2,000. So by 12 April the bot was requesting a page of somebody else's catalogue API every two seconds, indefinitely, and if the API started refusing it would retry every four seconds forever while a re-entrancy guard quietly held the whole notifier open.
I understand why each step happened — a notifier that tells you about a limited item four minutes late is not a notifier — and I do not think any individual change was unreasonable. The pattern is the problem: nobody asked me to slow down, so I did not, and the party absorbing the cost was never in the conversation. The first version of the client also sent a Chrome user agent:
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... Chrome/124.0.0.0 Safari/537.36'
That is a bot describing itself as a person to an API that did not choose to serve bots. I replaced the hand-rolled client three days later with rozod, a typed Roblox endpoint library, which is better code and also removed that line — not because I reconsidered it, but because the library does its own requests. I want the honest version on the record: I improved that behaviour by accident.
The same days contain a smaller, funnier correction. One pull request removed the filter restricting notifications to verified creators and added a third catalogue stream; the next pull request, the same day, put the filter back and deleted the stream. Titled Revert verified creator. And two days after that, a migration widening the price columns from INT to BIGINT, because resale prices on limited items overflow a signed 32-bit integer. In December 2023 I wrote an article about making prices real at the day job. Two and a half years later, on a different project, in a different language, prices again.
A card drawn twice
Introducing member card: 3,317 additions across eighty-nine files, and at its centre a 682-line Svelte component that renders a member's card — avatar, display name, top role in the role's colour, level, experience, rank, message count, voice minutes, join date — and lets them download it as a PNG.
The download is what I want to talk about. There is no html-to-image dependency. The card is painted a second time, by hand, onto a canvas:
let CH = PAD_TOP + headerH + 18 + avatarBlockH + 14 + nameH + 8;
if (role) CH += roleH + roleGap;
else CH += 8;
CH += levelLabelH + 2 + levelValueH + 18 + statBoxH + 6 + joinedH + PAD_BOT;
That is layout arithmetic I would normally never write, next to a hand-rolled roundRect built from arcTo calls and a hand-rolled drawStar that walks five points round a circle. The constraint that forces it is one line:
img.crossOrigin = 'anonymous';
Avatars come from Discord's CDN. A canvas that has drawn a cross-origin image without CORS is tainted, and a tainted canvas cannot be exported — toBlob throws. So the export path has to load every image with CORS, which means the export path has to be its own code, which means the design now exists twice: once as markup and CSS, once as drawing commands. They will drift. When someone asks me to change the card's spacing, I will change it in one place and ship it, and the downloaded version will be subtly wrong for a while.
I would still do it. A card you can only look at inside a panel is a feature; a card you can post is a reason for someone to talk about the server. But the cost is a duplicated design, and it is the second duplication I will describe in this article.
The same pull request added an operator broadcast: a superadmin-only endpoint that pushes one embed to every running bot, which fans it out to every server. Images can be attached by uploading first and referencing the file, which is guarded with a basename, a filename allowlist regex, and a check that the resolved path is still inside the uploads directory. Three defences for one path traversal, one of which is redundant after the first. I will take redundant.
An account token in a modal
I have described the bot's quest feature in previous months without describing what it does, and continuing to do that would be a choice rather than an omission.
The bot has a button. A member presses it, a modal opens, and they paste their own Discord account token. The bot then drives Discord's quest endpoints as that user, optionally through an HTTP proxy, and reports back in the channel. It exists because Discord quests award rewards for activity, and doing them by hand is tedious. It is automation of a user account, which Discord's terms prohibit, and it requires the member to hand over a credential that is equivalent to their whole account.
What the code does about that, in both languages the bot speaks:
"pendingDescription": "A result embed will be posted in this channel shortly.\n\nYour token is **not**
saved in our database.\n\n**Risk:** Discord may restrict or ban the **user account** tied to that
token; in some cases the **bot or server** could also be affected. This is not official Discord
behaviour — you chose to proceed."
The token is not persisted, the risk is stated before the work starts, the warning says the account may be banned, and the Indonesian translation says the same thing rather than a softened version. That is the most I can say in the feature's favour, and I think it is worth something: the person pressing the button knows what they are risking, in their own language, in the message that appears when they press it.
It does not make the feature a good idea. I built a thing whose best-case outcome is a small reward and whose worst-case outcome is somebody else's account being taken away, and the mitigation is a paragraph of text. Compare that to how I handled the same question in June, when I shipped a gambling endpoint to the same audience with no reasoning recorded at all. April at least argued with itself.
April's work on it was two guards. A per-user lock, so one member cannot start a second run while their first is going:
const activeEnrollUsers = new Set<string>();
export function queueOrbEnrollJob(job: OrbEnrollJob): void {
activeEnrollUsers.add(job.requesterId);
void runOrbEnrollJob(job).finally(() => activeEnrollUsers.delete(job.requesterId));
}
And a validation call before queueing, so an expired token produces an immediate "Invalid token" reply instead of a background job that fails somewhere the user cannot see. Both are the right instinct. The lock is an in-process Set, so it does not survive a restart and would not hold across two bot processes — the same limitation as the Redis singleton lock I wrote about in 2025, arrived at from the other direction, and the reason a lock in June had to be moved into the database.
A sync that could not delete, twice in one day
On 10 April I shipped a pull request on the bot called Sync should including delete. The guild synchronisation had been written as an upsert over whatever Discord returned:
-if (!categories || categories.length === 0) return true;
If a server deleted all its categories, Discord returned an empty list, and the function returned immediately — leaving every stale row in place forever. Even with a non-empty list, an upsert can only ever create and update. Nothing in the code path could represent the absence of something. The panel showed channels and categories that had not existed for weeks.
That evening, in a different repository and a different language, I wrote this commit:
Fixing woirker
+ DB::table('embeddings')->where('table_name', $table)->where('row_id', $rowId)->delete();
One line, into the embedding worker on this site. Without it, re-embedding an article that used to produce five chunks and now produces three leaves chunks four and five behind — orphaned vectors, still indexed, still matching searches, describing text that no longer exists.
Same mistake, same day, PHP and TypeScript, two products. The general form is that an upsert cannot express deletion, and every synchronisation I write has to be told that separately because the happy path never demonstrates it. The typo in the commit message is preserved as found.
Two titles that told me nothing
Reading April's diffs properly, the most useful thing I learned is that my own pull request titles are not evidence. Three examples from one month:
"Cleanup" 101 files. Public server pages, live leaderboards,
a members page, server-sent event streams, and the
first version of the quest automation.
"Refactor leveling button Demo mode with 520 lines of seed data, a demo login
components" endpoint, session expiry, and OpenTelemetry.
"Better handling cache" Inverted the ownership model, made four permission
functions async, added a route guard table.
"Stabilize bot feature" Deleted four already-applied migration files.
"Fix global embed" Extracted a 307-line embed form component and
reduced its caller from 255 lines to 59.
If you read only the titles you would conclude April was a quiet month of tidying. My first draft of this article did exactly that, and reproduced the titles as though they were a summary of the work. They are labels I typed while opening a pull request, and they were not written for anyone, including me.
The one that matters most is Cleanup. That pull request is where the bot got public pages — a server's own leaderboard and member list, on the open web, updating live over server-sent events, with 1,792 lines of CSS. It is the first thing this project made for people who are not in the Discord server. Calling it cleanup is not modesty, it is a failure to notice what I was doing.
Two pull requests I threw away
Two pull requests opened at the end of April were closed without merging, and I can now say what they were.
The first, on 18 April, is a landing page rewrite: 691 additions against 150 on the page shipped a week earlier, and 393 lines removed from the stylesheet. The second, on 29 April, is an analytics dashboard — 1,888 additions, a heatmap of activity, engagement scoring, channel health metrics, a 105-line migration adding analytics tables, and 661 lines of database queries.
Neither has come back. Not in May, not in June. And the bot itself went quiet: after 391 commits on the default branch in April, May has one. Whatever April was — and 395 contributions on a personal project alongside a full-time job is not a sustainable number — it ended abruptly, with the last two weeks' most ambitious work discarded and the repository silent for a month.
I do not have a note explaining the closures. Reading the diffs, my guess is that the analytics dashboard was built on schema I was not confident about — the migration adds tables rather than deriving from what leveling already records — and that the landing page rewrite was undoing a design I had shipped eight days earlier. But that is reconstruction, not recall. The honest summary is that I built two substantial things at the end of a very fast month and then stopped, and stopping was probably correct.
Two hours and eighteen minutes
Sixteen commits landed on this site on the evening of 10 April, between 17:09 and 19:27, and they are the reason this site can answer a question rather than only match a keyword.
There was already an EmbeddingService. That evening it grew a worker, a chunking model, and a place to run:
'articles' => 'SELECT id, title, description FROM articles WHERE enable = 1',
'projects' => 'SELECT id, title, description FROM projects WHERE enable = 1',
'experience' => 'SELECT id, title, period, description FROM experience',
'service' => 'SELECT id, title, description FROM service',
'skill' => 'SELECT id, title, type FROM skill',
'testimonial' => 'SELECT id, name, company, description FROM testimonial',
'github_activity' => 'SELECT id, repo, title, type FROM github_activity',
Seven tables — every kind of content on this site, including the GitHub activity feed that these retrospectives are built from. Each row is stripped of HTML, hashed, and skipped if the hash matches what is already stored, so editing an article's typo does not re-embed it.
The backfill is a resumable query rather than a loop over everything:
SELECT a.id, a.title, a.description FROM articles a
LEFT JOIN embeddings e ON e.table_name = 'articles' AND e.row_id = a.id
WHERE a.enable = 1 AND e.id IS NULL
Ask for what is missing, embed one row, exit. Called repeatedly by a worker, it converges, and it can be killed at any point without losing its place. The worker itself runs inside the same container as the web server, under supervisord, which was added to the FrankenPHP image that evening along with three environment variables to pace it: one second between rows, five seconds when idle, an orphan prune every five minutes.
The chunking is the interesting decision. The unique key on the embeddings table changed from (table_name, row_id) to (table_name, row_id, chunk_index), so one article becomes many vectors. And then, at 18:33:
-private static function chunkText(string $text, int $chunkSize = 2048, int $overlap = 256): array
+private static function chunkText(string $text, int $chunkSize = 500, int $overlap = 50): array
Two thousand characters is most of a section of one of these articles. A single vector over that much text averages away the specific thing you were looking for — the whole point of chunking is that the match should point at a passage, not a document. Five hundred characters with fifty of overlap is a passage.
The migration for that key took three attempts in fourteen minutes. The first version simply dropped the old unique index and added the new one, and broke where the old index was not there. The second version asked Doctrine's schema manager for the list of index names and guarded each step on it. The third version changed one condition from "create if missing" to "drop if present, then always create", because the existing index had the right name and the wrong columns — and a check on a name tells you nothing about a definition. Fourteen minutes, three deployments, and the lesson is that IF NOT EXISTS on an index is a weaker guarantee than it reads as.
Hybrid search, tuned by hand, in two languages
Retrieval on this site runs two engines and merges them. MySQL full-text search in boolean mode with prefix wildcards, and cosine similarity over the vectors. Neither is good enough alone: full-text cannot find "the month I made the prices real" from "how do you handle money", and vectors cannot reliably find an exact product name.
They are combined with reciprocal rank fusion, which ignores both engines' scores and uses only their orderings — the right instinct, because a BM25 score and a cosine similarity are not comparable quantities. On 10 April I retuned it:
- $K = 60;
+ $K = 40;
...
- $scores[$key] = ($scores[$key] ?? 0) + 1.5 * (1 / ($K + $rank + 1));
+ $scores[$key] = ($scores[$key] ?? 0) + 2.0 * (1 / ($K + $rank + 1));
Lowering K from 60 to 40 steepens the curve, so being first matters more relative to being fifth. Raising the semantic weight from 1.5 to 2.0 says that when meaning and keywords disagree, trust meaning. Both are judgement calls with no measurement behind them, made by typing questions into my own site and deciding whether the answers were better. I should say that plainly: this is tuned by feel, and it is the sort of thing where feel is how most people tune it and also the reason most people cannot tell you whether it improved.
Chunking forced one more change. With five vectors per article, a good match now returns the same article five times, and the top of the result list becomes one document's chunks:
const key = `${row.table_name}:${row.row_id}`;
const existing = best.get(key);
if (!existing || similarity > existing.similarity) {
best.set(key, { table_name: row.table_name, row_id: row.row_id, similarity });
}
Keep the best chunk per row, discard the rest. The threshold went up from 0.3 to 0.5 in the same commit, and the candidate pool from 20 to 50 — be stricter about what counts as a match, and look at more of them, which are complementary rather than contradictory once every candidate is a passage instead of a whole article.
And here is the second duplication of the month. That deduplication, that threshold, that fusion constant and that weight exist twice: in PHP in SimilarContentService, which powers related content in the admin panel, and in TypeScript in api/terminal, which powers the terminal on this site. Same algorithm, same numbers, two languages, kept in step by me remembering to change both. On 10 April I did remember. There is nothing in either file that mentions the other.
One new thing that evening was a query rewriter:
content: `Generate 3-5 short search query variants for a portfolio site search.
Return only the variants as a JSON array of strings, nothing else.
Original query: "${query}"`
...
const match = text.match(/\[[\s\S]*\]/);
if (match) { const parsed = JSON.parse(match[0]); ... }
} catch {}
return [query];
A language model expands "how do you handle money" into several phrasings, all of which get folded into the full-text query, capped at thirty words. I like the defensiveness of it more than the idea: the model is asked for JSON and not trusted to return only JSON, so the code hunts for the first bracketed span, and every failure path silently returns the original query. An enhancement that cannot break the thing it enhances.
What is not there is a vector index. Vectors are JSON columns in MySQL, loaded and scanned in application memory — in PHP, in chunks of a hundred rows, with cosine computed per row; in TypeScript, over a cached array with precomputed norms. Every query touches every vector. At this site's size that is fine and measurably fast, and I would rather write down the actual architecture than imply there is a vector database behind it. There is a table and a loop.
The rest of that evening was smaller: the /contribute page's sort moved from the browser to the server, a top-repository section removed, a loading spinner added, and the sort control redesigned for touch. Then nothing on this site until 29 April, when I restored the original favicon.
A private repository on 24 April
On 24 April I created a repository I have not mentioned in any of these retrospectives, and being transparent about the month means describing it accurately rather than leaving a gap.
It is a private project about self-hosting the enterprise edition of NocoDB, an open-source alternative to a well-known hosted spreadsheet-database service, and the mechanism it automates is bypassing that edition's licence verification. I am naming the product because I published two public articles about it that same week, so there is nothing to be coy about. I am not going to describe how the bypass works, here or anywhere, and the repository stays private.
What I did publish, on 24 and 25 April, were two articles on this site. The first is about NocoDB as a self-hosted alternative to a hosted service. The second, The 37MB binary surgery, is the one I would stand behind: it is written entirely as a defensive argument about where verification belongs, and it supplies no recipe for anything.
Its case is that three layers which each look secure can compose into something that is not. Obfuscation treated as a boundary rather than a speed bump, when any code the machine can run a motivated person can understand. Cryptographic signature verification performed by the client — strong mathematics, checked by the party whose trustworthiness is in question, which the article calls a lock installed on the inside of the door. And a remote validation call whose destination the client itself can influence, so the application asks an authority it believes in and gets an answer it was always going to accept.
The remedies it argues for are the ordinary ones: verify server-side, pin certificates so redirecting a hostname does not help, and add behavioural checks that client modification cannot see.
I think that article is genuinely useful, and I also think there is an obvious tension in learning those lessons by defeating somebody's licensing. The knowledge is real. So is the fact that the thing which produced it circumvents a commercial control that a company built to get paid. I do not have a resolution for that, and given what this series is for, a gap where April sat would have been worse than the honest version.
What April was
Fifty-nine merged pull requests across three repositories, a fourth one started, and 789 contributions. At the day job: March's two reverts redone, one of them revealing that my original guard had been reading a field that never contained the value; a shipping-method restore whose diagnosis quietly changed between the second and third attempt; three days and 346 lines of instrumentation to find that the storefront and the admin panel were sharing a session cookie; a flash sale that merged unchanged three weeks after being closed; a thousand-line rewrite of a button; and an AI reviewer wired onto every pull request.
On the bot: a landing page, a terms of service, a privacy policy, a demo mode, public per-server pages with live leaderboards, a downloadable member card, and an ownership model rewritten from the database up so that four permission functions could stop answering true. On this site: semantic search, from an idea to a running worker in two hours and eighteen minutes, and a hybrid retrieval pipeline tuned by hand in two languages.
The lesson I would actually carry forward is the one I found by accident, on 10 April, in two repositories at once: an upsert cannot represent deletion. A guild sync that only ever added rows, and an embedding worker that only ever wrote chunks. Both of them worked perfectly for every case I had tested, because nothing I tested had ever removed anything. I have made that mistake enough times now — October 2025's reconciliation, November 2025's duplicate experience rows, this month twice in one day — that I should treat "what happens when this disappears" as a required question rather than a good instinct.
The second lesson is smaller and more embarrassing. When I first wrote this article I described twenty-two pull requests on my own project by quoting their titles, and every substantive claim I made from those titles was wrong: a 9,360-line access-control change that was mostly a lockfile, a "stability by removal" that was deleting migration history, a "cleanup" that was the project's first public web pages. The titles were written by me, about my own code, weeks earlier. They were still not evidence. The diff is the only thing that is.