September 2026 was the month the Philippines started taking money, the month someone else replaced the core of the shop underneath me, and the month the reasons I deleted from the code in August turned up somewhere better — mostly in pull requests I did not write.
3cat-Sdn-Bhd/3cat 244 contributions (private) 134 pull requests, 50 mine
dansday-com/dansday-discord-bot 225 commits 20 pull requests
dansday-com/dansday-main 8 commits 2 pull requests
Dansday/Dansday 1 commit
dansday-com/.github 1 commit
Four hundred and seventy-nine contributions. The day job is still the larger half of my own month, but for the first time it is not the larger half of its own repository: of the 134 pull requests opened there in September, 84 were someone else's. In August I wrote that somebody else was in the repository. In September they did most of the work in it, and the most important change of the month is theirs.
Someone else replaced the core
Last month I wrote that the day job vendors Bagisto into its own tree, and that every upgrade there is a hand-resolved merge for precisely that reason. On 8 September the upgrade happened, and it was not a hand-resolved merge.
Bagisto 2.3.11 -> 2.5.0-beta1
Laravel 11.48 -> 13.30.1
PHP 8.3 -> 8.4.25
Tailwind 3 -> 4
Bagisto 2.5 + Laravel 13 + PHP 8.4 upgrade reports as 356,044 additions and 207,993 deletions across 2,954 files, which is a meaningless number on its own: almost all of it is packages/Webkul being deleted and replaced with a pristine copy of upstream. The interesting part is what made that safe, and it arrived as four documents before a line of code moved.
The first, a fork-delta audit, diffed the vendored tree against the version it claimed to be and found twenty-five differing files, one of them real code, and four real deltas in total. Each of the four was resolved on the application side, under what the document calls the team's standing rule: nobody edits vendored core. Its title states the conclusion — zero core changes needed. The second audited the application code against Bagisto's own upgrade guide. The third enumerated every place this shop extends or overrides a core class and diffed each signature against upstream:
30 override points
4 BREAKING-FATAL
1 BREAKING-RUNTIME (silent -- fails only on one admin action)
3 BREAKING-BEHAVIOUR
21 OK
The headline risk was a relation renamed upstream that would have broken every cart-rule save. That is the class of failure a hand-resolved merge produces and nobody notices for a week, found by reading the diff before running it.
The fourth document is the one I would point at. Before reporting the upgrade branch's test results, it measured master's:
master, run 1 1000 passed 76 failed
master, run 2 1007 passed 69 failed
master, run 3 1030 passed 46 failed
upgrade branch 1720 passed 41 failed (deterministic)
Master was unstable. Twenty-three of its failures were order-dependent tests polluted by shared state in the product indexer, and a core of forty-six failed every run regardless. Without that baseline, "41 failed" reads as a broken upgrade. With it, the upgrade is the most stable this test suite has ever been. The repository got its first CI in August; in September it got its first measurement of what the CI was actually saying.
The first fixes after the merge found real bugs rather than upgrade fallout — among them a dead translation-namespace registration that had been silently breaking every shop:: translation in the application. The upgrade did not cause that. It made it visible.
Thirteen pull requests about the cascade
What the upgrade did cause was CSS. Thirteen of my colleague's September pull requests have css or Tailwind in the title, and one of them names the root cause of most of the rest:
v3 emitted: [plain utilities] [imported CSS] [responsive variants]
v4 emits: [plain utilities] [responsive variants] -- inside @layer utilities
then imported CSS, unlayered, outranking the whole layer
Two hand-written stylesheets in this shop depended on where Tailwind v3 happened to put them. One of them bumps every text size a step from 1024 pixels up, which is why the templates say text-lg instead of text-base xl:text-lg. Under v4 it moved, and fonts across the desktop site changed size. The admin had the mirror-image problem: an unlayered admin stylesheet injected after core's started beating every one of core's layered utilities, responsive ones included, and the order list lost its layout. The fix there is a ninety-four-line script that strips from the shop's output any utility core already defines.
The test that came with the first fix is called CascadeGuardTest, and it exists because the position of a rule in a generated file had been an undeclared dependency for three years. Nothing in the code said the font scale relied on coming after the utilities and before the variants. It just did, until the generator changed its mind.
Postgres is a different database
The Philippines runs on Postgres, on Supabase. Malaysia runs on MySQL. It is the same code on the same branch, and September was a list of ways that is not the same program.
The team rule, stated in the pull request that enforced it, is no raw SQL in custom code, with no exceptions. Twenty occurrences became zero, and the hard part was not the queries but the exports: two export classes read values straight off the raw rows, so moving a computation into PHP would have silently changed what lands in a downloaded spreadsheet. Three migrations still used MySQL-only syntax and stopped the Philippine environment from migrating at all. And then this, measured rather than reasoned about after the tech lead asked whether a migration could reset the order numbers:
floor 423828 on a table whose max(id) is 900000
MySQL 8.0 clamps -- the ALTER is silently ignored next id 900001
PostgreSQL 16 obeys -- setval() moves the sequence next id 423828
On MySQL that migration was harmless. On Postgres it would have walked the order sequence backwards into ids that already exist, and the collision would have surfaced weeks later as a failed insert on somebody's order.
Two more of the same shape. Philippine orders were stored eight hours in the future: Laravel writes a Manila wall-clock time with no offset, the Postgres session was in UTC, and a 4:48 PM order read back as 12:48 AM. MySQL's naive datetime columns store and return the wall clock untouched, which is why Malaysia never saw it. The fix is six lines. And editing a product in the Philippines made its images disappear: fifteen rows across five products pointed at lowercased object keys, every file was still in the bucket under its original name, and S3 keys are case-sensitive. The fix stops the copy step asking Supabase for an access control list. Malaysia's storage had always accepted the request. That difference, as the pull request put it, was the diagnosis.
The Philippines started taking money
My own half of the month was the Philippine checkout, and it went the way a checkout goes: build it, then delete the parts production disagreed with.
It started on the 2nd with Traffic source configuration (view-src) for partner app webviews — 970 additions across forty files, so the shop knows when a visitor arrived from inside a partner's app. A resolver, a middleware that captures the source, finance-partner forms, seeded partner methods, configuration for GCash and Home Credit. Within a day two follow-ups had taken it apart: Remove complicated settings, 54 lines out, and Remove src gated, 34 more out of the resolver. In August the instalment work went the other way — a flag on a product that turned out to need a table. This time the first version was the elaborate one, and most of its configurability did not survive contact with the people configuring it.
Cash on delivery followed on the 3rd: a payment method, a per-product cod_unavailable attribute so a phone can opt out, ninety lines in the cart's JavaScript controller, and changes to two of the test files. I wrote those test changes myself, which is new.
Then the gateway. Payment Gateway Integration merged on the 10th at 2,335 additions, and its largest files describe a flow built from parts:
468+ src/Payment/PayMongo/Payment/PayMongo.php
375+ src/Http/Controllers/Frontend/PayMongoController.php
248+ src/Payment/PayMongo/Callback/PaymentProviderCallback.php
163+ src/Payment/PayMongo/ErrorMap.php
154+ src/Payment/PayMongo/Api/Client.php
84+ resources/views/web/pages/paymongo-status.blade.php
82+ resources/js/controllers/paymongo_status_controller.js
Payment intents, payment methods created and attached by hand, a QR code extracted and cached, a status page of our own, and a script polling it until the money arrived. Within a day, two pull requests had removed 876 of those lines. Post production checkout fix for paymongo deleted the status page, the poller, and every method that built the intent by hand. Updated webhook for production and staging deleted the bespoke webhook controller outright, 307 lines of it, and pointed the gateway's webhook and return URL at the shared payment gateway controller.
That is the right ending and I would rather have started there. The new gateway arrived with its own private callback path, and production — real customers, real money — is where it was decided that a second callback path was a liability. The month's other checkout work was smaller and the same in spirit: SPayLater and Billease showing a monthly price on the product page and at checkout, Philippine phone number prefixes, a fifteen-day return policy made configurable, and an exit-intent popup that opens Messenger rather than WhatsApp.
Every page in the Philippines asked not to be indexed
Malaysia's environment is called production. The Philippines' is called production_ph. Three places in the code asked whether they were in production by comparing against the literal string:
resources/views/web/master.blade.php config('app.env') !== 'production'
src/Tracking/MetaPixel/ConversionApiService config('app.env') !== 'production'
src/Console/Commands/SendConversionsToMeta $env != 'production'
All three answered no. So every page of the live Philippine site carried noindex, nofollow, and both pieces of server-side conversion reporting behaved as though they were not in production. Indexing is now its own setting, SEO_INDEXABLE, rather than an inference from a name. The general point is one this series keeps arriving at: June turned a country into a configuration value, and the environment name was still carrying a second meaning nobody had written down.
The cutover that broke every product image
Malaysia moved from CloudFront to Cloudflare in September, and I did the nginx half. The pull request that followed the cutover explains itself better than I can:
CloudFront had a cache behavior routing the S3_FOLDER prefix to the assets
bucket. Cloudflare has no equivalent origin rule, so after the cutover those
requests fell through to PHP and 302'd to the homepage -- every product image
on the site broke.
A rule that lived in one CDN's configuration, and nowhere in the repository, stopped existing when the CDN did. nginx now proxies that prefix to the bucket directly on all four Malaysian hosts. It is one of the five pull requests out of fifty that I wrote a description for.
The same fortnight produced three caching lessons from the other side of the repository. A header added with always stamped a year-long immutable cache on error responses, so when a QA bucket was private for a few minutes, Cloudflare cached the resulting errors and kept serving them after the bucket came back. A mismatch between the CDN's bypass list and nginx's meant a handful of customer paths told the browser to keep them for ten minutes, which an edge purge cannot reach. And the catalogue's stale window went from fifteen minutes to a day, on the measurement that a cache hit on the Philippine site answers in 53 to 70 milliseconds and a miss in three seconds.
A year ago, in The Month I Stopped Invalidating the Cache, eleven months of invalidation logic became a ten-minute nginx lifetime. The ten minutes are still there. In the Philippines they have never once applied: every response carries a session cookie, so Cloudflare treats every page as dynamic. The pull request that fixes that is still open.
One hundred and fifty-nine queries
The Philippine homepage issued 159 database queries and took about nine seconds to render on QA. Grouped by shape:
n ms sql
21 903 product_attribute_values ... product_id in (?)
19 667 products + product_flat join
17 640 attribute_families ... id in (?)
14 498 cart_rules where status = ? and conditions is not null
13 446 category_translations ... category_id in (?)
10 359 categories via product_categories pivot
Most lines in that table are the same query run once per product. The first pull request removed forty-four of them, and ten more followed the same day: a category and its children's products in one query, a product's bundles fetched once instead of once per variant, the cheapest sibling for every category in one pass. None of this is new. It is the shop's recurring injury, a property that is cheap on one product and ruinous across forty. The same code runs in Malaysia; in the Philippines each of those queries pays a round trip to a database further away, and a latent cost became nine seconds.
The tenth voucher bug, and a docblock
July's article counted nine months in this series with a voucher bug. September makes ten, and it is three characters wide:
- ... && $cartRule->uses_per_coupon !== 1) {
+ ... && $cartRule->uses_per_coupon < 1) {
The gate that decides whether a voucher code is advertised publicly hid a code only when its usage limit was exactly one. A code limited to two uses, or five, or a hundred, was shown to every shopper on the product page and at checkout, which for a limited code is the opposite of the point. Now anything with a limit is hidden and only unlimited codes are advertised.
And the first pull request I merged in September, Cat care should be turning off, fixed the CatCare add-on still appearing after it was switched off in the admin. The fix is a single gate, and it arrived with this:
/**
* Single gate for whether CatCare may be offered at all.
*
* CatCare is only offered when its catalog product exists, is enabled and
* allows guest checkout. Turning the product off in admin (or deleting it)
* must hide CatCare from the PDP, cart, checkout and confirmation page.
After August, I noticed. That is not a comment restating the code. It is the rule the code enforces and the four places it has to hold, which is exactly the kind I deleted a month earlier and said I should not have.
Four environment files left the tree
The last pull request I merged in September is titled Fix: bundle status gate. It is 118 additions against 624 deletions, and the bundle status gate is most of the additions. Most of the deletions are these:
0+ 132- 3cat/.env.dev_ph
0+ 132- 3cat/.env.production_ph
0+ 132- 3cat/.env.qa_ph
0+ 132- 3cat/.env.staging_ph
The four Philippine environment files that August's article listed as most of what a second country is. Earlier in the month the Philippines moved to a container image that reads its configuration at run time instead of baking it in, so the files had stopped being used; the same pull request removed the old Philippine build steps from CI and the two-country logic from the local Makefile.
In July I wrote, without details, that the repository holds material it should not, and that the fix is the boring one: move it somewhere the deployment reads at boot, rotate it, and treat the history as compromised rather than assume nobody looked. September did the first of those for one country. I am going to hold to the same line as July and say only this much: removing a file from the tree does not remove it from the history, Malaysia has not made the same move, and a cleanup of that size should not ship under a title that describes none of it.
Logs nobody could read
One pull request opened in September and merged on 2 October belongs here, because it explains a silence. The scheduler and the queue workers wrote their output to files on a container volume that every deploy replaced, and the log shipper only collected standard output, so none of it ever left the machine. A scheduled job that sends conversions to Meta stopped producing output on 3 July, and its input files had been piling up unprocessed since 11 September, with no error anywhere to read. It is the same lesson as the forwarder outage I wrote about on the 26th, one layer out: a failure that logs nothing is indistinguishable from success.
The month ended on two reverts
On the 28th the Philippine homepage gained partner logos and an As seen on strip: e27, Manila Bulletin, The Edge Malaysia, TNGlobal, Business Today. On the 29th a feedback round adjusted it and added a test that the partner row fits. On the 30th both were reverted, a minute apart. The descriptions on the reverts are the ones GitHub writes on its own — Reverts 3cat-Sdn-Bhd/3cat#2311 — and nothing else. Whatever the reason was, the repository does not know it.
The bot got a new face, and a new licence
Two hundred and twenty-five commits and twenty pull requests on the Discord bot. The first big one, Revamp UI/UX for public, merged on the 5th at 7,358 real additions against 11,525 deletions across ninety-nine files, and 8,187 of those deletions are the old stylesheet. The 5th itself is thirty-five commits in one day: Adding three js, Adding globe live, Add lenis, Addding hero animation, Adding glass effect. A spinning globe and a smooth-scrolling library went onto the homepage in an afternoon. Nothing about either was measured.
The same pull request replaced the licence. LICENSE went from MIT to the GNU Affero General Public License inside a change titled as a UI revamp. That is a decision about what other people may do with the code, and it is the most consequential line in a hundred-file diff about layout. On the 28th the organisation profile got a commit titled Correct license claims, which is what happens when a licence changes in a place nobody reads.
A link anyone could compute
Leaking account hashing, on the 7th, is fifty-seven lines and the most important change on the bot this month. Every member has a public account card, and the link to it is a credential: whoever holds it opens that member's page. Here is how the link was made:
-export function computeCardToken(discordMemberId: string, memberSince: any): string {
- return createHash('sha256').update(`${discordMemberId}_${joinedDate}`).digest('hex').substring(0, 16);
A hash of a Discord id and a join date, truncated to sixteen characters. Both inputs are visible to anyone who shares a server with you, so anyone could compute anyone's link. A hash is not a secret; it is a fingerprint of things that may already be public. The replacement is what it should have been from the start:
+export function computeCardToken(discordMemberId: string): string {
+ const secret = process.env.SECRET;
+ if (!secret) throw new Error('SECRET is not configured; account card links cannot be signed.');
+ return createHmac('sha256', secret).update(`card:${discordMemberId}`).digest('hex');
An HMAC keyed on a server secret, the full digest rather than sixteen characters of it, and a constant-time comparison when a link is checked. Rotating the secret invalidates every link ever issued, which the settings page now says in so many words. I am describing it at class level, as usual, and noting that it is fixed and public.
Fifty effects on one canvas
Member card themes arrived on the 10th, and on the 12th the effects behind them were rewritten from CSS animations to a 2D canvas: 4,896 additions against 12,315 deletions, 10,716 of them from the stylesheet. I wrote about why in 39 Effects, One Canvas, and a Lot of Cleanup on the 14th — iOS 18 broke the CSS version — so here I will only record the shape of the following week. Eleven effects to reach fifty. Fifteen more. Five removed. A commit titled Rewrite and audit new effects. And on the 20th, Fix 4k laggy and Fixing visibility engine, each reverted the same day. Much of the month's commit count is this, and most of it is one person tuning animations by eye.
The voice upgrade that stopped calling tools
Upgrade to support gemini live 3.8 merged on the 23rd, and the next day's commit list is a search: Fix tool usages, Fix parameter tool lost, Adding more log to tools calling, Trace voice tooling, Fix extended thinking for 3.8 live, Simplify thinking. The voice model had stopped calling tools. The cause is in two lines:
- if (!caps.scheduling) return { id: call.id, name: call.name, response };
- return { id: call.id, name: call.name, response: { ...response, scheduling: FunctionResponseScheduling.INTERRUPT } };
+ if (!caps.asyncTools || !caps.scheduling) return { id: call.id, name: call.name, response };
+ return { id: call.id, name: call.name, response, scheduling: FunctionResponseScheduling.INTERRUPT };
Asynchronous tool declarations are a capability of the previous model generation, and stamping them onto the new one silently disabled tool calling; and the scheduling hint belongs beside the response, not inside it. Neither produced an error. The model simply stopped using tools and said nothing, which is the hardest kind of failure to debug and the reason half the fixes are logging.
A removal that renamed, and a validator that was no longer needed
Selfbot removal, on the 25th, did not remove the selfbots; they are still there under a new table name. Its centre is a 143-line migration that renames server_bots to selfbots, and the tables around it, each rename wrapped in a check that the old table exists. What it did remove is the quest proxy setting, and with it the validator I added in August and described as server-side request forgery closed at two layers, along with the proxy library underneath it.
I was pleased with that validator, and it was correct. The better fix for an input you have to validate that carefully is usually to stop accepting it.
The next day was the forwarder — memory, CPU and concurrency on a two-core box — and that has its own article, published on the 26th.
A domain, subdomains and three more languages
On the 24th the bot moved to its own domain. On the 25th every public server got a subdomain of it. On the 28th the panel moved under /admin, eighty-five files of route changes. And on the 29th Arabic, Malay and Chinese arrived at 508 lines each, which makes seven languages. Around those: quest rewards that can be claimed all at once, Roblox catalogue notifications, a per-server AI personality, uploads moved to S3, a permissions tab deleted outright, and custom roles and staff ratings simplified.
The bot still has no tests
August's article ended its bot section by saying the bot has no tests. At the end of September it has two hundred and twenty-five more commits, seven languages, a public directory listing and a link-signing secret, and still no test files and no CI workflow. On the 9th I added issue and pull request templates. Every one of the ten bot pull requests merged after that has the template's placeholder text still in its description, unfilled: What this does and why, in a couple of sentences.
This website stopped remembering by meaning
Eight commits and two pull requests on dansday-main. Uploads moved to S3 on the 9th. And on the 30th, Removal embedding: 784 real additions against 1,515 deletions once the lockfiles are set aside.
0+ 400- admin/app/Services/EmbeddingService.php
0+ 291- admin/app/Services/SimilarContentService.php
223+ 0- admin/app/Services/WebToolsService.php
235+ 0- main/src/lib/server/terminal/web.ts
In April I shipped semantic search in an evening: embeddings for every article and project, fused with MySQL full-text search by reciprocal rank. In September it went. The terminal now searches with full-text alone, and instead of recalling by meaning it can look things up on the web. The line deleted from the contributing guide says what the embeddings were costing: every content field had to be added in two write paths, and the embedding index kept in sync with both, or recall went stale. A feature that has to be remembered on every change is a tax, and this one was not paying for itself.
Two corrections
August ended with nine commits rewriting my GitHub profile for recruiters in one evening. September has one commit on each profile repository, and both begin with the same word: Correct workflow automation figure and tenure, and Correct license claims and rewrite org profile. The README written in a hurry claimed things that were not quite true, and it took a month to notice.
What September was
At the day job: a core upgrade done with four audits first and a measured baseline, by someone else; a cascade that moved and took the fonts with it; a second database that disagreed with the first about sequences, time zones and the case of a filename; a Philippine checkout with cash on delivery, two buy-now-pay-later prices and a gateway that lost a third of its code the day after it shipped; a whole country asking search engines to ignore it because of a string comparison; every product image in Malaysia broken by a rule that lived only in the old CDN; nine seconds of homepage turned into a list of queries; the tenth voucher bug; four environment files gone from the tree under the wrong title; and two reverts with no reason.
On the bot: a new face and a new licence, an account link anyone could compute, fifty effects on a canvas, a voice model that stopped using tools without saying so, a validator made unnecessary, a domain, subdomains and seven languages. No tests.
On this website: semantic search, removed.
August's article ended on a sentence I meant: a test tells you when something breaks, but it does not tell you why the line was written. It did not say where the why should go instead, and September answered that, though not from me.
Every one of my colleague's eighty-four pull requests has a description. The median is about three thousand characters, and they read like the paragraphs above — because most of the paragraphs above were written from them. They contain baselines, tables of query shapes, the behaviour of two database engines on the same statement, headed in one case Measured, not assumed. They sit attached to the diff they explain, dated, never needing to be kept in sync with the code, which is everything an inline comment is not. Five of my fifty have a description. The median length of mine is zero. On the bot I added a template asking for one and then merged ten pull requests without filling it in, and two of the last three things I merged at the day job are reverts whose only explanation is the one GitHub generates.
So the thing to carry into October is smaller than August's and more concrete. The reasons I deleted from the code belong somewhere, and the right place for most of them is the pull request. The person who showed me that this month did it by example, eighty-four times. I should manage it more than five.