Dansday

The Month I Deleted the Reasons

Published on Aug 30, 2026

August 2026 was the month a second country stopped being a design document and became an environment file, and the month I deleted the notes explaining why it worked.

3cat-Sdn-Bhd/3cat                139 contributions (private)   41 pull requests
dansday-com/dansday-discord-bot   89 commits                    13 pull requests
dansday-com/dansday-main          54 commits                     7 pull requests
Dansday/Dansday                    9 commits
dansday-com/.github                1 commit

Two hundred and ninety-two contributions. The day job is the majority for the first time since March, and for the first time in this series it is not a repository I am alone in: eight of its forty-one pull requests were opened by someone else. That changes what these articles are about, and I will come to it.

The Philippines stopped being a design

June's article ended with a two-country design that closed without merging, and I wrote at the time that what survived was not the architecture but the removal of the assumption that made it impossible — the hardcoded currency. Two months later the architecture arrived anyway.

2081: PH Infrastucture setup, merged 11 August, is 1,898 additions across forty-nine files and almost none of it is application code:

201+  3cat/config/3cat/stores_ph.php
137+  scripts/deploy.sh
135+  3cat/.env.dev_ph
131+  3cat/.env.staging_ph
131+  3cat/.env.production_ph
130+  3cat/.env.qa_ph
113+  environment/nginx/.../hub-qa.3cat.ph.conf
111+  environment/nginx/.../hub-staging.3cat.ph.conf
 78+  qa.tf
 69+  3cat/config/global_ph.php
 60+  3cat/config/home_ph.php
 44+  3cat/src/Providers/CountryServiceProvider.php

Four environment files, nginx virtual hosts for 3cat.ph across dev, QA, staging and production, a terraform change, a deploy script that now takes a country, and a service provider forty-four lines long. The second country is mostly DNS, TLS and configuration. The interesting consequence is in the six pull requests that follow it in the same two days — Fixing semaphore build, Fixing QA deployment for both countries, Fix config on footer, Revert active naming on deploy scripts, Auto deploy support stopped instances, Fix ssl on dev. Standing up a second country is not one change. It is one change and then a day of finding every place that assumed there was one of everything.

What a country actually is, in this codebase

The application half arrived separately, and not from me. 2126: per-country config foundation is 2,275 additions across 113 files, opened on the 13th and merged on the 24th, and it establishes a directory:

3cat/config/countries/my/checkout.php    195+
3cat/config/countries/my/faq.php         176+
3cat/config/countries/my/global.php      103+
3cat/config/countries/my/home.php         61+
3cat/config/countries/my/delivery.php     53+
3cat/config/countries/ph/checkout.php     48+
3cat/config/countries/ph/delivery.php     41+

Alongside it, resources/lang/en/web.php loses 163 lines. That is the mechanism worth naming: copy that used to live in a translation file, keyed by string, moves into a per-country configuration file, keyed by country. The two are not the same thing and the codebase had been using one for the other. A language file answers "what do we call this in English". A country file answers "do we do this here at all".

The shape of a checkout.php makes the distinction concrete. Malaysia declares xox, a Malaysian telco, and cc_instalment_eghl, a Malaysian gateway. The Philippines file does not declare them as empty — it omits them. Both return null from config(), so every guard behaves identically, but absence states that the concept does not exist there where an empty array would state that we have none yet. That is a genuinely good distinction and I did not make it.

The tests that came with it

The same pull request brought something this repository had never had, in the two and a half years I have been writing about it:

3cat/tests/P3cat/Unit/CountryConfigParityTest.php   209+
3cat/tests/P3cat/Unit/SectionGuardTest.php          133+
3cat/tests/P3cat/Unit/CatCareEligibilityTest.php     66+
3cat/tests/P3cat/Unit/DeliveryDateTest.php           65+
3cat/tests/P3cat/Unit/TradeInValueTest.php           52+
automation/playwright/country-config.spec.js         66+
automation/playwright/smoke.spec.js                  47+

And four days later, in the deposit work, a .github/workflows/tests.yml that runs ./vendor/bin/pest --testsuite="P3cat Unit Test" on every pull request and every push to master. A shop that has been taking real money since 2023 got its first continuous integration in August 2026. I am not going to pretend that is a triumph. It is a thing that should have existed for three years, and the reason it exists now is that somebody else joined and wrote it.

The parity test is the one I would point at. It walks config/countries/* and asserts that every country declares the same shape, so that adding a third country fails loudly at the point of omission rather than quietly serving the first country's answer. Which is exactly what had been happening.

The Philippines was silently getting Malaysia's answer

The reserve block in config/countries/ph/checkout.php carried a note when it was written, which I am quoting from the diff that removed it:

Declared rather than inherited from the shared config/checkout.php, which
is where it used to live alone -- PH was silently getting MY's answer.
Carried over as-is so nothing changes today, but reserve-and-collect needs
stores: if PH launches without them this is 'enabled' => false, and
cart_controller.js has to stop reading fully_paid_reservation unguarded
first (#2126 AC#1 vi).

Reserve-and-collect — pay a deposit online, collect in a store — requires stores. The Philippines has none yet. Until this configuration existed, the feature's settings lived in one shared file, so the Philippine storefront was inheriting Malaysia's reservation window, Malaysia's collection lead times and Malaysia's nine-o'clock cutoff, for stores that do not exist.

Eleven days later, in 2126-2: Country with no stores hides every store surface, that enabled flag went to false and a has_stores() helper appeared. So the note was right, the prerequisite was real, and the change it predicted was made. The note itself was deleted in the same pull request that made it.

The month I deleted the reasons

This is the thread that runs through every repository in August, so I am going to state it plainly rather than distribute it across sections.

I do not like code comments. I have said so for years, and the reasoning is ordinary: a comment is a claim that is never type-checked, never executed and never fails, so it rots while the code beside it changes. I still believe that about most comments. What I did in August is not that.

2126-2 touches a hundred files, and among them src/helpers.php loses every explanatory block it had. Here is one of them, in full, as deleted:

/**
 * Link to the country's default messaging channel -- WhatsApp in MY, Messenger
 * in PH. The channel is a config value, not a branch here, so a new country
 * needs a config entry and no change to this function or its callers.
 */
function generate_chat_link(...)

    // "&"/"#"/"+" in a product name truncate the query string unescaped;
    // __CHAT_PREFILL__ survives rawurlencode() untouched.
    return sprintf(config('global.chat.link'), $handle, rawurlencode($prefillText));

The second comment documents a bug fix made ten days earlier, in a pull request titled Encode the chat prefill so "&" stops truncating the message. A product name containing an ampersand was cutting the prefilled chat message in half. The rawurlencode call is the fix; the comment is the only record of what it is defending against. The call survived. The reason did not. The next person to look at that line sees an encode with no adversary, and encodes are exactly the kind of thing that get removed as redundant.

The same pull request removed the docblock on normalize_payment_additional, which described the shape of a double-encoded JSON payload and how gateway keys are normalised, and the docblocks on delivery_date and delivery_business_days, which explained why business days are declared separately from lead days rather than converted. It did not remove the docblock markers. The file now contains this:

/**
 */
function delivery_date(string $stockState, string $mode = 'delivery', ...): ?Carbon

An empty docblock is worse than either option. It is a comment that survived its content, and it is what a find-and-replace looks like when it is not read afterwards.

The same instinct ran on this website in the same fortnight. 41: Author strip and comments is 68 additions against 1,743 deletions across eighty-four files, and once you set aside the migration that drops an author column, almost all of it is comment removal: 47 lines from config/database.php, 46 from config/mail.php, 38 from config/cache.php, and thirty to thirty-six lines from each language file. Those are Laravel's own annotations, shipped with the framework, listing the supported drivers for each option. Deleting them is free today and costs on the next framework upgrade, when the incoming config file and mine no longer share a single line of context. This repository already knows what that costs: the day job vendors Bagisto into its own tree, and every upgrade there is a hand-resolved merge for precisely this reason.

I am not going to conclude that I was wrong to dislike comments. I am going to record that in August I removed a colleague's explanations of a live bug fix, a country-inheritance hazard and a payment payload shape, left three empty docblocks behind, and stripped a framework's own documentation out of four config files, and that none of that was the thing I object to when I object to comments.

The chat button, again

No single element in this codebase has appeared in more of these articles than the chat call-to-action. In August it stopped being a WhatsApp button.

2026_08_27_000000_rename_whatsapp_widget_theme_type_to_chat_widget.php

A migration renames the admin theme type, the blade components go from whatsapp-widget to chat-widget, and a chat_sprite() helper resolves the icon from config('global.chat.channel'). Malaysia keeps WhatsApp. The Philippines gets Messenger. The button that has been rewritten in more months than anything else in this shop finally stopped naming a vendor, and it took a second country to force it.

Pay at store, without paying

On 12 August a hotfix merged with the title Paid Reservation orders bypass payment gateway. It is thirty-three lines and it is the most serious thing in the month.

The checkout decides the payment method in JavaScript:

-const isPickup = this.isSelfPickupSelected();
-if (isPickup) {
+const isFreeReservation = this.isFreeReservation();
+if (isFreeReservation) {
     defaultPaymentMethod.payment.method = 'pay_at_store';
 }

Free reservation — hold an item, pay nothing now, pay in the store — is supposed to be the only flow that resolves to pay_at_store. The condition being tested was self-pickup, which is a different and larger set: a paid reservation is also collected in a store. So a cart with a deposit due could arrive at pay_at_store, skip the gateway, and produce an order with no payment attached to it.

The fix corrects the client-side condition, and then does the thing that actually matters:

if (
    $paymentGateway === PayAtStore::METHOD
    && $cart?->shipping_method !== FreeReservation::METHOD
) {
    $this->paymentLogger->logPaymentError(
        errorType: 'invalid_payment_method_for_shipping',
        message: 'Pay at store rejected for non free reservation cart',
        ...
    );

    return new JsonResponse([...'success' => false]);
}

The server now refuses the combination regardless of what the browser sends. April 2026's article was called The Month My Guards Didn't Guard, and its subject was a permission table defeated by a check that returned true. This is the same shape one layer down: a rule that existed only in the client, which is not a place where rules exist. The difference in August is that the fix went to the server rather than to a better client-side condition, and it logs the rejection with the cart id and shipping method rather than failing silently. That is the right correction and I want it recorded as the right one.

Deposits, and the tests that finally came with them

Four pull requests between the 14th and the 21st rebuilt the deposit flow: Updated Reservation Flow, Improve deposit when payment failures, Fixing parameter of deposit to EGHL, and Deposit should go into paid reservation. The middle one is where the tests are:

211+  tests/P3cat/Unit/CheckoutMatrixTest.php
 61+  tests/P3cat/Unit/PickupWindowTest.php
 55+  tests/P3cat/Unit/OrderFlowFactoryTest.php
 51+  tests/P3cat/Unit/SessionDepositTest.php
 46+  tests/P3cat/Unit/OrderDepositTest.php
 47+  src/Order/SessionDeposit.php
 37+  src/Order/PickupWindow.php
 45+  .github/workflows/tests.yml

Two new small classes, five test files totalling 424 lines, and the CI workflow. A checkout matrix test is the correct answer to this particular problem: the bug in the hotfix above existed because shipping method, payment method and deposit state form a grid, and only some cells are legal. Writing the grid down is how you stop discovering the illegal cells in production.

The exit-intent popup became infrastructure

The exit-intent popup has eight pull requests behind it across this series, in which triggers were removed one at a time and then added back. In August it was rebuilt as something else entirely.

205+  3cat/resources/js/experiments.js
142+  3cat/experiments/exit-intent-popup/script.js
128+  3cat/experiments/exit-intent-popup/Offer.php
 97+  3cat/experiments/exit-intent-popup/views/variant-1.blade.php
 82+  3cat/src/Experiment/PostHogClient.php
 75+  3cat/src/Experiment/Experiment.php
 70+  3cat/src/Experiment/ExperimentServiceProvider.php
 55+  3cat/experiments/exit-intent-popup/Provider.php
 48+  3cat/src/Experiment/ExperimentRegistry.php
 20+  3cat/config/experiments.php

That is roughly 1,400 real lines once the 136-line composer.lock change is set aside, and it is an A/B testing framework with PostHog feature flags behind it. Experiments live in a top-level experiments/ directory as self-contained plugins with their own views, language files, styles and service provider. The registry decides which experiments apply to which page types and hands the client a payload.

The design note in config/experiments.php is worth quoting because it is the one comment I would defend without reservation:

Set 'enabled' => false to switch an experiment off everywhere at once: the
variant markup, CSS, JS and events all stop being emitted and every visitor
falls back to the permanent design. Experiments run in every environment,
so this is the only switch.

"Experiments run in every environment, so this is the only switch" is a real constraint stated in the only place a reader will look for it. That is what a comment is for. It survived because nobody has run a comment sweep over that file yet.

What I notice, three years into writing about this shop, is that the popup got a framework before the checkout got a test suite — the framework merged on the 11th, the checkout matrix on the 19th. Eight days is not a scandal. The ordering is still the ordering.

A correction: the promotion resolver came back

Last month's article described a pull request called LCP Improvement, which moved promotion frames out of two API endpoints and a lazy client script and into a resolver that runs during render. It was reverted the same day. I wrote: "I still believe that. It was reverted anyway, and it has not come back."

It came back on 4 August, three weeks later, under a different title.

344+   0-  3cat/src/Promotion/ProductPromotionResolver.php
 61+  62-  3cat/resources/views/web/scripts/pdp.blade.php
 12+ 277-  3cat/src/Http/Controllers/Api/PromotionController.php
 10+  21-  3cat/src/Http/Controllers/Frontend/ProductController.php

Same move, smaller: a 344-line resolver replacing 277 lines of API controller, scoped to the product page rather than to every card on every listing. July's version was 476 lines and took the listing pages with it, which is the half I speculated had caused the failure. This one left the listings alone and it has stayed merged.

The title is 2051: UI Clean Up (2). Ticket 2051 is the same ticket as July's revert sequence, the one that went through three levels of reverting. So the record I published a month ago is wrong in its last sentence, and the correct version is duller and better: the change was right, the blast radius was wrong, and shipping a third of it in July and the rest of it in August is what a careful recovery from a bad deploy looks like. I wrote it up as a defeat because I stopped reading at the revert.

Instalments became a database

On 28 August, 2159: Add per-product flag to configure max installment tenure landed as 1,287 additions across thirty files, with six migrations:

2026_08_28_100000_create_finance_partners_table.php
2026_08_28_100001_create_product_tenure_configs_table.php
2026_08_28_100002_seed_finance_partners.php
2026_08_28_100003_backfill_product_tenure_configs.php
2026_08_28_100004_seed_finance_partner_logos.php
2026_08_28_100005_add_effective_dates_to_finance_partners.php

Finance partners become rows with effective dates, per-product tenure limits become a table with a backfill, and there is an admin data grid, a form, a TenureResolver service and a listener that keeps the product configuration in sync. An earlier attempt at the same ticket, opened the day before at 51 additions across seven files, was closed unmerged. The gap between the two is the whole story: the first version was a flag on a product, and the second is the recognition that a maximum instalment tenure is a fact about an agreement with a lender that starts and stops on dates, not a property of a phone.

This is the same lesson as the currency in June and the country configuration this month. A value that looks like a constant when there is one of something turns out to be a relationship as soon as there are two.

Somebody else is in the repository

Eight of the day job's forty-one pull requests in August were opened by another engineer, and they are not small. Two of them built the country configuration foundation and its tests. One added the Playwright suite and a local stack that runs one country at a time. One fixed the ampersand truncating chat prefills. One is a Postgres compatibility pass that was closed unmerged. Two are a staged withdrawal from Redis.

I have written thirty-two of these articles about a codebase I was effectively alone in, and the tone of them — every mistake is mine, every deletion is a confession — assumed that. It no longer holds, and the honest consequence is that the best engineering in this month's day-job work is not mine. The country config directory, the parity test, the section guards and the first CI this repository has ever had all came from someone else. What I contributed to that effort was infrastructure underneath it and a hundred-file pull request on top of it that deleted its documentation.

Seventy seconds of sleep inside a web request

Phase 1 cleanup: dead code, Redis debug reads, OTP fire-and-forget removes 758 real lines once the two lockfiles are set aside. The one I want to show is in the one-time-password sender:

-private const int MAX_RETRIES = 3;
-private const int RETRY_DELAY_SECONDS = 10;
-private const float RETRY_DELAY_MULTIPLIER = 2;
...
-$attempts = 0;
-$delay = self::RETRY_DELAY_SECONDS;
-while (! $this->attemptToSendOTP($phoneNumber, $otp) && $attempts < self::MAX_RETRIES) {
-    $this->createContact($phoneNumber);
-    sleep($delay);
-    $attempts++;
-    $delay *= self::RETRY_DELAY_MULTIPLIER;
-}

Three retries with exponential backoff at ten, twenty and forty seconds. sleep() in PHP is synchronous and this ran inside the request handling the login form, so a customer whose first two send attempts failed waited seventy seconds staring at a form while a PHP-FPM worker sat blocked doing nothing. Under any real concurrency the pool exhausts and the failure spreads to people who were not requesting an OTP at all.

The replacement is one immediate retry, on the grounds — stated in a comment, which I mention because it is the correct use of one — that the first send commonly fails for brand-new contacts at the provider and creating the contact fixes it. That is the actual failure mode. The backoff was defending against a transient outage that was not what was happening.

The same pull request also deleted a 315-line buildSessionDiagnostics method from the vendored Bagisto admin middleware, which read Redis directly to investigate a fast-logout bug. Three hundred and fifteen lines of instrumentation for a bug that is presumably fixed, sitting inside an authorization middleware in vendored upstream code. Removing it is right. That it was ever there is the vendoring tax showing up again.

The OTP cache moved off Redis, and that has a condition attached

The same change moved the OTP cache store from Redis to the local filesystem:

 'otp' => [
-    'driver'     => 'redis',
-    'connection' => env('CACHE_CONNECTION', 'cache'),
+    'driver'    => 'file',
+    'path'      => storage_path('framework/cache/data'),
 ],

A file cache is local to the container that wrote it. A one-time password is created by whichever worker handled the send and verified by whichever worker handles the check, and those are only guaranteed to be the same process if there is exactly one. This is safe on a single application container and becomes an intermittent verification failure the moment there is a second, in a way that will look like customers mistyping their codes.

I am stating the class of the problem and not the deployment topology, per the standard I set in December. The follow-up pull request, still open, moves sessions and the cache to database tables with their own migrations, which resolves it correctly if it lands. There is also a separate small fix from the same author two days later — cast code to string before caching — because mt_rand() returns an integer and the comparison on verification is a string one. That bug and this one are the same bug at different layers: a cache is not a variable, and everything that goes through one needs its type and its locality thought about.

The bot learned to read wikis, wake up, and draw

Eighty-nine commits and thirteen pull requests on the Discord bot, and the first four days of the month are a coherent burst.

1083: Connecting AI with knowledge of Wikimedia adds a 475-line wiki.ts, a panel page for managing wiki sources per bot, a test endpoint and a migration. Any MediaWiki site, including Fandom, many per bot, reading the full rendered page rather than the API summary so that infoboxes, tables and changelogs come through. 1084 follows immediately with relays for wikis that block the server outright.

1085: Implement wake voice local is the one I like. The voice assistant previously joined a channel and listened. Now it waits for a wake phrase detected on-device:

156+  components/wakeWord.ts
  0   static/wakeword/hey_stupid.onnx
  0   static/wakeword/melspectrogram.onnx
  0   static/wakeword/embedding_model.onnx

Three ONNX models shipped into the repository, running an openWakeWord pipeline locally. The reasoning is in the README rather than the code: a busy channel never triggers it, and there is no per-utterance API cost for audio nobody meant to send. The published line count for this pull request is 927 additions; 479 of those are package-lock.json, so the real change is about 448 lines. I mention it because the next section is about a line count that is almost entirely lockfile.

1086: Implemented image generation adds a 202-line webTools.ts and a 97-line imageTools.ts, with images uploaded to Discord as files rather than linked, so nothing breaks when a provider's temporary URL expires. That is a small decision that will save a class of bug report entirely.

A 1,592-line security audit that was ninety-three lines

1091: Improved security audit reports as 1,592 additions and 887 deletions. Here is the file list in full:

1499+  876-  package-lock.json
  72+    2-  src/lib/utils/questHttpProxyUrl.ts
   9+    9-  package.json
   8+    0-  src/routes/api/servers/[id]/settings/+server.ts
   4+    0-  src/lib/backend/api/discord-quest-api.ts

Ninety-three real additions. The dependency bumps are worth having and they are not an audit; the audit is the seventy-two lines in the middle.

The quest notifier lets a server administrator configure an outbound HTTP proxy. The validation it had accepted any URL whose protocol was http: or https:, which is to say it accepted addresses inside the hosting network and the cloud metadata endpoint. That is server-side request forgery with a settings form as the entry point, and on a multi-tenant panel the tenant supplying the address is not the party who owns the network.

The replacement rejects loopback, link-local, private and carrier-grade ranges, IPv6 unique-local and link-local, IPv4-mapped IPv6, hostnames ending in .internal or .local, known metadata hostnames, and integer or hexadecimal encodings of an address. It is enforced in two places — when the setting is saved, and again when the proxy agent is constructed — which is the correct arrangement, because validation at write time alone leaves every row written before the validator existed.

I am describing this at class level because that is the standard in this series, and noting that it is fixed and the fix is public in an MIT-licensed repository.

The bot learned to set itself up, and to explain itself

Two large pull requests in the second half of the month rebuilt onboarding. 1089: Improve setup flow reduces commands/admin/setup.ts from 305 lines to eleven and moves everything into a 1,308-line setupFlow.ts. The command description changes from "creates a category with all required channels. Administrator only" to "Set up the bot, or check and repair an existing setup. Owner or Administrator only." A setup command that can be run twice is a different product from one that can be run once.

1090: Setup improvement is 3,649 additions across eighty-nine files and is mostly a documentation and tooling layer: a 570-line docs.ts, a 207-line guide.ts, and three new AI tool modules totalling 1,100 lines — serverTools.ts, accountTools.ts and knowledgeTools.ts.

Those tools let the chat model answer questions about the server it is in. serverTools reads what is already public: leaderboards across twenty-one metrics, the shop catalogue, server statistics. accountTools reads the caller's own account, and the scoping is done properly — the caller's Discord id resolves to a member row and everything else derives from that, so there is no tool that takes a member name and returns their data. That is the right design and it was not the obvious one.

The one thing I would flag is voice_buddies: the members you share voice channels with most, by name, with minutes and XP earned together. It is scoped to the caller, and the same figures are already on the public account page, so this is not new exposure. It is still a social graph of other people, and it is now something a language model will volunteer in conversation rather than something you go and look at. Those are different in practice even when the data is identical.

The bot became an official Discord application

The last week of the month on the bot is a compliance run, and I have written about it separately, so here I will just record the mechanism. 1093 adds 333 lines of Terms of Service and Privacy Policy as structured data, enumerating every third party the bot forwards data to. A companion change adds a seven-day retention window with a purge that actually runs, marking servers the bot has been removed from and deleting them and their members when the window closes. 1094 adds German and Spanish. A dependency audit, a security policy, a code of conduct, a contributing guide and a licence file complete it.

What is worth saying in a retrospective that is not worth saying in the announcement: writing the privacy policy was the first time the full list of third parties this bot touches existed in one place, and the retention purge was the first answer to "what happens to my data when I remove you". Both of those questions had been outstanding for a year and neither was asked by me.

The bot has no tests

In August the day job gained a Pest suite, a Playwright suite and a CI workflow. This website gained five test files and a CI workflow. The Discord bot — eighty-nine commits, the most active repository of the three, the one that now appears in a public directory and holds other people's member data under a published retention promise — has no .github/workflows directory and no test files.

Every correctness claim in the last four sections is a claim I verified by reading. There is nothing that would catch a regression in the retention purge, the SSRF validator or the account-scoping in the AI tools except me noticing. The gap between the standard I am now holding the day job to and the standard I hold my own project to is the widest it has been in this series, and it widened in the month I published a privacy policy.

This website grew an interface for machines

Fifty-four commits and seven pull requests on dansday-main, all in the last week. 40: Introducing MCP is 3,775 additions across fifty-one files and gives the admin panel a Model Context Protocol server:

972+  admin/app/Services/ContentWriteService.php
235+  admin/app/Mcp/Tools/AboutTools.php
216+  admin/tests/Unit/ContentWriteHelpersTest.php
197+  admin/resources/views/admin/pages/mcp.blade.php
187+  admin/app/Http/Controllers/McpServerController.php
165+  admin/tests/Unit/McpProtocolTest.php
141+  admin/app/Mcp/Tools/ArticleTools.php
138+  admin/app/Mcp/Tools/ProjectTools.php
137+  admin/tests/Unit/McpToolRegistryTest.php

Note the tests, in the first pull request rather than the fifth. Whatever else August was, it is the month testing became a default in two of three repositories.

50 and 51: Improve linkedin mcp capability add LinkedIn publishing, and the second one is where the security work is: a 172-line SafeUrlFetcher with its own test file, an upload path safety test, an MCP upload feature test, and a 99-line CI workflow. The same class of problem as the bot's proxy validator, handled the same way, in the same month, in a different language. That is either a good instinct generalising or one lesson being applied twice; the honest answer is that I cannot tell from the commits and there is no note.

The article describing all of this went up on the 29th, which means this website is now the only one of my projects that documents itself as it changes.

And then a README for recruiters

The month closes on the 29th with nine commits to my GitHub profile repository and one to the organisation profile:

Add profile README
Rewrite for recruiters and clients
Sharpen the headline
Serve GitHub stats from dansday.com, fix badge logos
Use avatar red as accent, drop unrenderable logos
Match top-language badges to theme palette
Plain website badge
Remove whitespace inside badge anchors
Revert README to e215c37

Note the revert at the end and the two commits about badge logos not rendering. Nine commits to a README on a single evening is what it looks like when the work is done and the presentation of it is not, and I include the sequence unedited because the ordering is funnier and more accurate than any summary of it.

What August was

At the day job: a second country that turned out to be four environment files and a week of finding singular assumptions; a configuration directory that made "do we do this here" a different question from "what do we call it"; the first automated tests and the first CI in the repository's history, written by someone else; a checkout that could produce an order without a payment, fixed on the server rather than in the browser; the exit-intent popup promoted to an experiment framework with feature flags; a promotion resolver returning three weeks after a revert I had written off; instalment tenures becoming a table with effective dates; seventy seconds of blocking sleep removed from a login flow; and a cache moved somewhere it will work until there are two of something.

On the bot: wikis, an on-device wake word, image generation, a rebuilt setup flow that can repair itself, AI tools scoped correctly to the caller, a server-side request forgery closed at two layers, a privacy policy, a retention purge, two more languages, and a listing in Discord's App Directory. No tests.

On this website: an interface that lets a machine write it, with tests in the first pull request, and a README rewritten nine times in one evening.

The thread I did not expect to find is the one about comments. I spent August adding tests in two repositories, which are executable claims about behaviour, and deleting prose claims about behaviour in three. Those are not opposites and I would defend the general preference on any other month. But the specific things I removed were a note explaining what an encode defends against, a note explaining that one country was silently inheriting another's configuration, and a framework's own list of supported drivers — and none of them were the rotting decoration I object to. They were the answers to questions the code does not contain.

A test tells you when something breaks. It does not tell you why the line was written. In a month where I finally started writing the first kind, I deleted a good deal of the second, and the two are not substitutes. If there is one thing to carry into September it is that the correct target was never comments. It was comments that restate the code. The ones I removed were the other kind.