March 2026 is the largest month in this series by a distance, and not because the day job got busier.
3cat-Sdn-Bhd/3cat 40 pull requests opened, 37 merged 167 contributions (private)
dansday-com/dansday-main 41 opened, 40 merged 405 contributions
dansday-com/dansday-discord-bot 24 opened, 23 merged 121 contributions
Six hundred and ninety-three contributions and a hundred and five pull requests in thirty-one days. I also published nineteen articles.
The middle line is the one that matters. dansday-main is this website — the one you are reading this on. It was created on 21 February and March was its first full month alive. Everything else in this article was written while building the thing that publishes it.
My first version of this article covered that middle line in three paragraphs quoting pull request titles. This version is written from the commits, and the commit log of one file — the terminal's API route, ninety-eight commits in March — turned out to be the best record of the month I have.
Two weeks of Docker
The first twenty pull requests on the site are almost entirely build and permissions, and the titles do not flatter me:
1: Fixing docker
2: Redudant removal for docker
3: Fixing permission and build +215/-836
4: include storage
5: Update permissions for default image in uploads directory
6: Add directory creation and permission setup
7: Remove unused volume definitions from docker-compose.yml
8: Refactor admin application setup by replacing SeedOnFirstVisit middleware
9: Refactor file storage and image handling in admin application +303/-150
10: Refactor public path handling in AppServiceProvider
12: Add docker-compose.override.yml for local development setup
13: Update Dockerfiles to handle package-lock.json conditionally
14: Update Dockerfile to improve dependency installation
15: Update Dockerfile to copy storage.php before running composer install
16: Update Dockerfile to optimize composer installation by skipping scripts
17: Enhance docker-entrypoint.sh to sync additional environment variables
Five separate pull requests adjusting one Dockerfile. Two about the permissions on an uploads directory. One that exists solely because composer install runs scripts that need a file which had not been copied into the image yet.
I am including that list in full because it is the honest shape of starting a project, and it is the part nobody writes about. Everything interesting in this article happened after two weeks of arguing with a container about whether a directory was writable.
One thing I did differently, and I noticed it while writing this. The third pull request on the repository is Add README.md to document project setup and architecture. At 3cat, the README arrived in month twenty-one, and when I finally wrote it I discovered a database living in a pull request and a build step nobody could discover. Here it was the third thing I did. That is what twenty-six months of writing these recaps has actually changed about how I work — not my judgement about caches or constraints, but the order I do the boring things in.
The terminal was pretending
The site's premise is that a portfolio should look like the tool the work was done in. So the front end is a terminal you can type into. It landed on 12 March, and I want to quote what it actually was, because the rest of the month is the story of it stopping being that.
const systemPrompt = {
role: 'system',
content: `You are an AI assistant integrated into a web-based Ubuntu terminal emulator.
Your username is "dansday@ai" and you operate in a CLI environment.
Respond to user input as if you are a terminal command output or a helpful CLI assistant.
Keep responses concise, formatted as plain text, and visually resembling terminal output where appropriate.
If the user types a standard Linux command, you can simulate its output or provide helpful information.
If they ask a general question, answer it concisely in a terminal-friendly format.`
};
A language model told to act like a shell. It had no access to a single thing about me — not an article, not a project, not a commit. If you asked it what I had been working on, it improvised. If you typed ls, it invented a directory listing.
Two things in that first version I would keep. The route logs every command with the caller's address, because a text box wired to a paid API is an invitation:
console.info(`[Terminal Activity] Command executed by IP ${clientIp}: ${lastMessage.content}`);
And the page refuses to exist when the feature is not configured — a server-side redirect rather than an error, so an unconfigured deployment simply has no terminal:
if (!data.aiTerminalConfigured) {
throw redirect(302, '/');
}
That is the same habit as the feature flags at the day job and the per-component switches on the Discord bot, and it is the one design instinct I trust about myself.
Thirteen hours on 20 March
Between 04:35 and 17:04 on 20 March I made twenty-two commits to that one file. The messages, unedited, are the clearest description of the work I could write:
04:35 Add ai tool
04:54 Fixing tool
05:05 fixing data fetch AI
05:41 Feed Ai with scoial links
06:05 Adding more data to AI
06:08 Combine with repo activity
06:20 Fixing data reterive of private repo
06:29 Fixing hallucination answer
06:34 Fixing dates
07:02 Meger PR and Commit into 1 table
07:07 Pertier format
07:35 Remove restriction to private pr
10:35 Tool getting orser sort
10:46 USe specidfic date
10:48 Adding total tools
11:43 Remove AI limit data read
12:32 Feed website domain
14:49 Pretier format
15:21 No more calling tools
15:41 USe toon format
16:08 Maximize ai token
16:51 Remove temp
17:04 Remove AI token cap
That is the day the terminal stopped improvising. Add ai tool gives the model functions it can call — get the homepage, get the about page, get articles, get projects, get GitHub activity — each backed by a real query. Everything after it is the discovery that giving a model tools is the easy half.
Meger PR and Commit into 1 table is a schema decision made mid-flight: commits and pull requests had separate tables, and merging them into one github_activity table with a type column is what makes "what did I do in April" a single query instead of a join with a union.
USe toon format is the one I would point a reader at. Tool results were being serialised as JSON, and JSON spends a lot of tokens on repeating every key for every row. Swapping to a compact tabular encoding cut the same data down enough to matter:
-return JSON.stringify(articles.map((a) => ({ title: a.title, description: a.short_desc, ... })));
+return toToon(articles.map((a) => ({ title: a.title, description: a.short_desc, ... })));
Five files, one import, every tool's output re-encoded. The three commits after it — Maximize ai token, Remove temp, Remove AI token cap — are me spending the budget I had just saved.
No more calling tools, at 15:21, is the commit I cannot fully reconstruct: tool calling was switched off, and five days later a commit called Changing tool to auto switched it back on with the model deciding when to call rather than being forced. Somewhere in between, forcing a tool call on every message was worse than letting the model choose.
The mask that became a sentence in a prompt
At 06:29 on 20 March I committed Fixing hallucination answer, and it is the most consequential five lines in the month.
The problem: I work on a private repository for a living, and the terminal was making things up about it. The reason it was making things up is that I had been careful. Private commit titles were destroyed before the model ever saw them:
title: r.is_private ? '*'.repeat(r.title.length) : r.title,
A row of asterisks the same length as the real title. Asked what I had been working on, the model received a list of dates attached to nothing, and filled the gap.
The fix sends the real titles, marks them, and asks the model not to repeat them:
title: r.title,
date: r.committed_at,
private: !!r.is_private
description: 'Get GitHub commit activity with real commit titles. ... Each commit includes a
"private" flag. Use the titles to summarize what work was done — never show raw commit titles
from private repos to the user, instead summarize the work topics.'
That works. It is also a downgrade in kind, and I want to be exact about it rather than let it pass as a bug fix. Before, the guarantee was arithmetic: the bytes were gone, and no prompt, no jailbreak and no model update could produce them. After, the guarantee is a sentence in a function description, addressed to a system whose entire job is generating text, which now receives my employer's private commit titles on every relevant question.
Four days later, at 01:00 on 24 March, a commit called Remove private mask took out the last of it — the statistics path had still been replacing private repository names with the literal string private-repo and excluding private rows from item lists in favour of a count. After that commit, real repository names and titles flow through with a flag.
The public contributions page renders them, repo name and commit title, with a small private badge next to them. So this was not an accident of refactoring; it is a publication decision, taken in two steps, and it is consistent with the rest of this series — these recaps quote private ticket text at length, deliberately, because a retrospective that cannot describe the work is not worth writing. But the terminal is different from an article. An article is a thing I wrote and read before publishing. The terminal is a live surface with my employer's commit titles behind it, held back by a polite instruction.
If I were reviewing this for someone else I would say: keep the flag, keep the summarisation instruction, and put the mask back in the query for anything that is not aggregate. The enforcement and the summary are not alternatives.
Four hours from keyword search to hybrid retrieval
By 23 March the terminal had a search tool, and its implementation was LIKE '%word%' repeated across every field of every table. It finds the word you typed and nothing else. On 25 March, between 07:30 and 11:44, it became something else entirely.
07:30 — Change serach algorthm to BM25. A migration adding MySQL full-text indexes to seven tables, and every LIKE replaced with a relevance-scored match:
Schema::table('articles', fn($t) => $t->fullText(['title', 'description'], 'articles_fulltext'));
Schema::table('projects', ...);
Schema::table('github_activity', fn($t) => $t->fullText(['repo', 'title'], 'github_activity_fulltext'));
Schema::table('experience', ...); Schema::table('service', ...);
Schema::table('testimonial', ...); Schema::table('skill', ...);
MATCH(title, description) AGAINST(? IN BOOLEAN MODE)
The database now ranks instead of filtering. Words are stripped of full-text operators and given trailing wildcards so partial words still match, capped at twenty terms.
08:34 — Introduce embedding system. Full-text search cannot find the article about caching when somebody asks about slow pages, because the word "cache" is not in the question. So every row in seven tables gets embedded into a vector, and the query gets embedded too, and similarity does the finding. Fifty-two minutes after adding full-text indexes I was already working around their limits.
09:26 — Fixing embedding business logic, which among other things batched the embedding calls twenty rows at a time instead of one request per row.
11:44 — Fixing embedding search weith by using rrf. The problem with two search engines is combining them, and a BM25 relevance score and a cosine similarity are not the same kind of number — adding them is meaningless. Reciprocal rank fusion throws away both scores and uses only the orderings:
$scores[$key] = ($scores[$key] ?? 0) + 1.0 * (1 / ($K + $rank + 1)); // keyword
$scores[$key] = ($scores[$key] ?? 0) + 1.5 * (1 / ($K + $rank + 1)); // semantic
Being first in either list is worth a lot, being fifth is worth less, and the constants say how quickly that falls away and which engine to trust when they disagree. That is the same pipeline I retuned on 10 April, from a different desk, with a different set of numbers.
The commits either side of it are the unglamorous part of retrieval: Adjust sematic threshold, Fixing data search with special character, Remove github type from embedding, Adding frequency penalty to punish conversion duplicated, and a pair two minutes apart — Improve tool guidance with personality at 19:34, Removing tool guide at 19:36 — which is what it looks like when you give a model a personality and immediately read it back.
Five providers in one night
Late on 25 March the work stops being about retrieval and starts being about whose model is answering:
21:42 Add support for nvidia NIM
22:16 Add support for other model
22:30 Fix coversation for qwen
02:05 Removing embedding cache *Testing*
02:36 Add support for gemini
05:03 Stream the output
05:11 Fixing loop tools
This is why the AI settings on this site are three text fields — a URL, a key and a model name — rather than a provider dropdown. It is not a design principle. It is the residue of an evening spent pointing the same code at NVIDIA's inference service, then Qwen, then Gemini, because I was looking for something that could run this feature at a price a personal site can carry. Each one is nominally OpenAI-compatible and each one is compatible in a slightly different way, which is what Fix coversation for qwen and, six days later, Fix OSS not streaming the respond are about.
Stream the output matters more than it sounds. A terminal that shows nothing for eight seconds and then prints a paragraph does not feel like a terminal. Streaming tokens as they arrive is the difference between a chat box in costume and something that reads like a command running.
And Fixing loop tools is the failure mode of letting a model choose its own tools: it calls one, reads the result, calls it again, and does not stop. The fix is a bound on how many rounds of tool calls one message may produce.
Clone services with terminal
On 26 March at 13:08 there is a commit called Clone services with terminal for better accuracy, and it is the origin of a thing I complained about in April's article without knowing where it came from.
The admin panel needed the same retrieval the terminal had — to suggest related content when writing a post. Rather than call the terminal's endpoint, I reimplemented the whole pipeline in PHP: SimilarContentService, 235 lines, with its own BM25 query builder, its own cosine similarity, its own rank fusion and its own constants. The word in the commit message is clone, so I knew exactly what I was doing.
Two implementations of one algorithm in two languages, and every tuning decision has to be made twice from then on. Three weeks later, on 10 April, I changed the fusion constant and the semantic weight in both files on the same evening, and got away with it. Nothing in either file mentions the other.
There is a smaller version of the same lesson in the two pull requests that closed the month. On 26 March I introduced an AiClientFactory, sixty-four lines wrapping the official OpenAI PHP package for chat and embeddings. Later the same day I deleted it and replaced every call with raw HTTP:
$res = Http::timeout(60)
->withHeaders(['Accept' => 'application/json', 'Content-Type' => 'application/json'])
->withToken($key)
->post($endpoint, $body);
An SDK adopted and removed inside twelve hours. It is the direct consequence of the previous evening: a client library built around one vendor's API is a liability when your actual requirement is "whatever endpoint I can afford this month", and raw HTTP with a normalised URL and a bearer token is both smaller and more portable. The pull request is titled Removing unused packages and changes to raw http, which is the most understated title in this article.
Twelve megabytes of icons
On 14 March I merged two pull requests that together add 415,000 lines to this repository.
Improve font awesome and seo +19,678 / -11,329 57 files
Add new icon metadata and update JavaScript +395,586 / -93 16 files
The first is a Font Awesome upgrade — the stylesheets, rewritten. The second is the icon metadata, committed so the admin panel can offer a searchable icon picker:
admin/assets/metadata/icon-families.json 5.3 MB
admin/assets/metadata/icons.json 4.8 MB
admin/assets/metadata/icon-families.yml 1.1 MB
admin/assets/metadata/icons.yml 0.9 MB
admin/assets/metadata/categories.yml 56 KB
admin/assets/metadata/shims.json 41 KB
Twelve megabytes, and the same two datasets are present twice — once as JSON, once as YAML — because that is how the vendor ships them and I committed the directory rather than the files I needed. They are still there today. Every clone of this repository downloads all of it so that a dropdown can autocomplete fa-shield.
I am not going to pretend this is a considered trade-off. It is what happens when you are moving fast at 14:00 on a Saturday and git add is one command.
Naming, slugs, and a change of clothes
Two pull requests in the last week are the month admitting its conventions were decided late.
Standarize naming and slug, on 23 March, is 365 additions against 1,039 deletions across forty-seven files — slug generation pulled into one place, article and project controllers rewritten around it, category routes added for projects, the sitemap taught about them, and 208 lines of seeder deleted. The next day, Update plural name for database, thirteen files, twenty-five lines each way: table names made consistently plural. A month-old codebase, and I had already accumulated enough disagreement with myself to need a pass over forty-seven files.
Then Changing to WSL look, on 24 March, which deleted a component called face.svelte and a navbar listener and moved their work into the layout. The site had been dressed as a desktop operating system with a face in the corner; it became a single terminal window. Same evening: Adding auto tab complete in terminal.
And one pull request I closed rather than merged: Adding elastic effect, 211 additions across two files, an elastic drag on the window chrome. Written, looked at, discarded.
The bot got a control panel and a notification system
Twenty-four pull requests on the Discord bot, and my first draft of this article covered eleven of them as a code block of titles. The three worth reading are all from 7 March.
Implement notification system with role management and UI integration is 1,122 additions and 916 deletions across twenty-six files: a notificationsSync component, notification settings per server, role-based targeting, webhook and sync integration, and 279 new lines in the control panel's HTML. Members opt in to a role; the bot notifies that role. It is the first feature on the bot that a server's staff configure rather than a developer.
Refactor shutdown process is 106 lines of the forwarder plus changes to the entrypoints of both bots. Shutting down a bot cleanly is unglamorous and it is the difference between a restart and a restart that loses whatever was in flight.
Add mention stripping functionality to message processing is 129 lines: strip user, role and channel mentions out of message content before processing. That is a small guard against a bot repeating a mention and pinging a hundred people, which is the sort of thing you learn once.
And the control panel at that point was frontend/index.html and frontend/index.js — hand-written markup and vanilla JavaScript, several hundred lines each, growing a section per feature. Remember that, because it matters at the end of the month.
One day of infrastructure, ending with a deletion
The 8th and 9th of March are sixteen pull requests, back to back, and the shape of them is familiar to anyone who has set up a deployment alone:
1028 Redis fix 1031 Add GitHub Actions workflow for deploying
1029 Redis configuration setup 1031 ...for deploying on release
1030 Port fixing 1033 Fixing auto deploy
1037 Refactor port configuration 1034 Fixing action
1038 Proxy setup 1035 deployment trigger
Five pull requests to get one deployment pipeline working, and then the sixth — nominally about a MySQL fix — deleted the release-triggered workflow entirely, ninety-six lines, twelve hours after adding it. Two ways to deploy became one.
That same pull request is worth quoting for the bug it fixes, because it is a database upgrade breaking correct code:
const lim = Math.min(500, Math.max(1, parseInt(limit, 10) || 100));
const off = Math.max(0, parseInt(offset, 10) || 0);
// MySQL 8.0.22+ rejects LIMIT/OFFSET when passed as numbers in prepared stmt; pass as strings.
const result = await query(`... LIMIT ? OFFSET ?`, [String(lim), String(off)]);
Newer MySQL will not accept a number as a bind parameter for LIMIT. It wants a string. The fix is String() around two integers, and the clamp in front of it — between 1 and 500, defaulting sanely on garbage — is the part I would keep even if the database had never changed. The comment explaining why is the only comment of its kind in that file, which tells you how long it took me to work out.
An earlier attempt at the same fix was opened and closed unmerged before this one landed.
Logs stopped being rows in a table
The best decision on the bot in March took four pull requests on 9 March and my first draft summarised it as one sentence which was also slightly wrong. Here is what happened.
Before: the bot's log output was human-formatted text, written to a MySQL table, which the control panel polled to display in a box:
async function log(text) {
const timestamp = formatTimestamp(Date.now(), true);
const formattedText = `[${timestamp}] ${text}`;
console.log(formattedText);
}
Step one added an OpenTelemetry SDK inside the application, 144 lines, exporting to SigNoz.
Step two, Remove old logs, deleted the log storage and the viewer — 140 lines of database access, twenty lines of schema, 467 lines of panel markup, ninety lines of panel JavaScript — and added a collector as a sidecar container instead:
receivers:
otlp: # traces, metrics, logs from instrumented apps
hostmetrics: # cpu, disk, load, filesystem, memory, network, paging, process
docker_stats: # per-container metrics from the docker daemon
filelog:
include: [/var/lib/docker/containers/*/*-json.log]
operators:
- type: container
format: docker
Step three deleted the SDK I had added the day before, all 123 lines of it. Once a collector is tailing Docker's own log files, an application that prints to standard output is already instrumented. The application stopped being responsible for shipping its own telemetry.
Step four made what it prints worth collecting:
function buildPayload(level, message, meta) {
const payload = { level, message, time: new Date().toISOString() };
if (meta && typeof meta === 'object') payload.meta = meta;
return JSON.stringify(payload);
}
One log() became info, debug, warn and error, each emitting a JSON line with a level, a timestamp and optional structured fields. Every call site across the bots, Redis and i18n updated.
So: log storage deleted, log viewer deleted, in-app instrumentation deleted, and what remains is a program that prints structured lines and a collector that reads them. Roughly 840 lines removed on the way to better observability than the project had ever had.
The cost, stated plainly because monitoring agents get a pass they have not earned: that collector runs as user: "0:0", in network_mode: host, with the Docker socket and the host root filesystem mounted into it. Anything that can write to that container's configuration can run anything on that machine. It is the standard arrangement for a host-level agent and it is a large amount of trust placed in one image tag.
Not a version bump
On 30 March I opened one pull request titled Migrating to use sveltekit 5: 31,604 additions, 25,294 deletions, 271 files. It merged on 4 April.
My first draft called this "a front-end framework major version, done in a single pull request". That is wrong, and reading the March file list is what shows it. The bot's panel was not a SvelteKit 3 or 4 application. It was frontend/index.html and frontend/index.js — hand-written markup and vanilla JavaScript served by Express, plus a backend of loose .js modules. This pull request is not an upgrade. It is a rewrite of the entire application into a SvelteKit 5 project with typed routes, server load functions, an API layer and components.
Which makes it worse, not better. Twenty-five thousand deleted lines are the old application; 271 files touched in one pull request, on a project with real users, by one person, at the end of a month in which he also merged thirty-seven pull requests at work and built a website from nothing. Nobody reviewed it. I would not have reviewed it. I merged it, and every structural thing I liked about April on the bot — the route guard table, the ownership migrations, the landing page, the member card — is only expressible because this landed first.
I do not think the decision was wrong. I think doing it as one pull request in one weekend was, and the reason I could get away with it is that the blast radius was mine.
Three or four hours became twenty minutes
At the day job, the best thing I shipped in March was internal tooling, and the ticket states its own case better than I could:
Currently, setting up a promotion takes Teega Ops 3-4 hours because the
process is fragmented across three separate pages:
- PDP: For product-specific discounts and subtitles.
- Default Promo Frame: For visual settings (borders, icons).
- Cart Rule: For backend logic (vouchers, dates, categories).
We are centralizing the entire workflow into the Cart Rule page to
reduce setup time to 20 minutes.
1,654 additions, 495 deletions, thirty files. Three or four hours down to twenty minutes, per promotion, for the people who run promotions constantly.
I built every one of those three pages. The product-page discount fields, the promotion frame configuration, the cart rule conditions — those are May 2024, June 2024 and October 2024 respectively, each shipped as its own well-scoped ticket, each sensible in isolation. Nobody decided that configuring a promotion should require three screens. It simply became true, one reasonable ticket at a time, over eighteen months, and the cost landed on somebody else's afternoon rather than mine.
Two more pull requests in the same week are the same shape. Enable Bulk Edit of Special Price on PDPs — 1,689 additions against five deletions — because the team could already bulk-edit the price but not the special price, and the ticket says plainly that doing them one at a time is tedious. And Enable bulk edit for stock type with new column export, 141 additions.
There is a lesson here I have been slow to learn. For two years I have optimised the customer's path obsessively — page weight, query counts, cache headers, checkout friction. The people who use this system for eight hours a day were editing variants one at a time.
A video that is not a video
The month's other significant feature was Virtual Inspection on PDP, 612 additions and 472 deletions across seventeen files, and I want to describe exactly what it is.
Videos are a POWERFUL conversion lever for (i) Chats (ii) Sales.
We want to clearly inform customers that we have videos available.
3) The Video screen has the following elements:
- frame follows the PDP Frame colour
- A fake video playback button
- A chat CTA "Hi Teega, please send me the insp[ection video]"
The second image on the product page is a video screen with a play button on it. The play button does not play anything. Tapping it opens WhatsApp with a message asking a human to send you the inspection video.
I do not think it is dishonest — the video genuinely exists, a person genuinely sends it, and the whole business model runs on that chat conversation. It is also the fourth time I have written a paragraph like this one. November 2024: a Low Stock badge reading almost gone on products whose quantity was zero. March 2025: Rare Gem! for devices nobody had bought. April 2025: a timer measuring how long you hesitated. Now a play button that is a picture of a play button.
The series started with an article about making a product page stop advertising discounts that did not exist. Twenty-seven months later I am building interface elements that promise slightly more than they deliver, one ticket at a time, and each one is individually defensible. I have stopped expecting that tension to resolve. Recording it is the only thing I can usefully do about it.
Two minutes in production
March contains the fastest revert in two years.
#1908 1869: PDP, Checkout Quick Fixes merged 27 Mar 17:58
#1925 Revert "1869: PDP, Checkout..." merged 27 Mar 18:01
Nine changed lines, live for roughly two minutes. Somebody was watching, something was wrong, and the fix was to put it back. I reopened the same branch as a new pull request the same evening and then closed that too. In April it came back at 446 additions across twenty-three files, which is what the ticket had actually needed all along.
The second revert is more instructive because the original was correct in intent. The ticket:
when a customer pays their remaining balance, the system automatically
resets the order status to "Ready to Process." This is problematic for
deposit-based orders (RM100) that have already moved into "Stock
Transfer" or other advanced statuses. The reset wipes out the tracking
history, causing the Teega team to lose visibility on the current
fulfillment stage.
A real problem, and downstream of split payments and paid reservations — features from June 2025 and earlier. Pay the second instalment on an order already being picked and packed, and the status machine throws it back to the start.
My fix guarded the promotion:
- $this->orderRepository->updateOrderStatus($order, OrderModel::STATUS_READY_TO_PROCESS);
+ if ($order->status === 'pending_payment') {
+ $this->orderRepository->updateOrderStatus($order, OrderModel::STATUS_READY_TO_PROCESS);
+ }
Only promote an order that is still waiting for payment. It merged on 27 March and I reverted it on 29 March.
Look at what I wrote. Every status in that file is a named constant — STATUS_READY_TO_PROCESS, STATUS_PARTIALLY_PAID, and I added STATUS_RESERVED myself back in March 2024. Then I compared against the string 'pending_payment', typed by hand, in a status machine.
I wrote a long paragraph here in the first version of this article about how a hardcoded string is fragile. April settled it, and the answer was worse than fragility: the correct field is sub_status, not status, and the value is a prefix rather than an exact match. The column I was reading never contained the value I was comparing against. The guard was false always, which means orders customers had fully paid for stopped advancing to fulfilment, and two days is about how long it took someone in the warehouse to notice.
The string literal was the symptom. The cause is that I wrote a condition against a column without checking what that column holds, in a state machine I had been working in for two years.
Webhooks in the wrong order
Three small pull requests I raised as my own issues, which together describe a category of bug I had not hit before.
Shipment created should send before order updated. Then, a fortnight later, Fixing shipment created webhook logical order, then Update webhook config, then Fixing array values. Seven lines, two lines, three lines, one line.
By 2026 this shop emits webhooks to other systems when things happen to an order. Those systems make assumptions about sequence — a shipment notification that arrives before the order update it belongs to describes a shipment for an order in a state that no longer exists. Nothing errors. The receiving system just builds a slightly wrong picture, and you find out from a human saying the numbers look odd.
It is the distributed-systems version of the stale-relation bugs I have written up four or five times: the same failure of assuming that because you did A before B, everyone else will see A before B.
December's hotfix became a public disclosure
On 20 March I published an article called Unprotected API on Bagisto e‑commerce installation.
Readers of the December 2025 entry will recognise it. That month closed with a hotfix that rotated the database and admin credentials across production, staging and QA, and patched the framework's installer, and I described it at deliberately low resolution because it was upstream code other people run.
In March I took the other route and wrote it up properly, in public, as a disclosure:
anyone can reach admin only routes without passing the intended
authentication checks
I observed this vulnerability present since at least version 1.3.0, and
it is still sitting right there in the latest stable release. If an
installation has not applied a custom patch, it is entirely exposed
The article asks the maintainers for four things — an audit of the affected component and its authentication pathways, a patched release, guidance for merchants on applying and verifying the fix, and a transparent disclosure process for future findings. It also notes that this is my second public report on Bagisto security, the first being a cookie-stealing issue I submitted in 2024 which was still unaddressed.
Four days earlier I had published a second one, on a client-side template injection flaw in Bagisto 2.2.0.
I want to be careful about how I frame this, because there is a version of it that is self-congratulatory and it would be false. I did not go looking for these. I found the installer problem in the last week of December while doing something else entirely, patched our own copy because we vendor the framework into our repository, and rotated credentials because I could not prove nothing had reached them. The disclosure came three months later, when I had a website to publish it on.
That gap is the uncomfortable part. Between finding it and publishing it, every other Bagisto installation was in exactly the state ours had been, and I was the one person who knew. I do not have a tidy defence. Our shop was fixed within hours; the ecosystem waited a quarter for a blog post.
The site is the argument
On 27 March I published an article on the new site titled Hi. Its summary line is: I am officially opening my doors to new opportunities.
That is the context for everything above, and it would be dishonest to write about March without it. I did not build a portfolio site in March 2026 because I wanted a nicer blog. I built it because I was starting to look for work, and the argument I wanted to make was not a CV. The nineteen articles I published that month include a piece on how the AI terminal finds relevant content, one on building the live contributions page, one on turning a single server into a personal developer platform, one on how I became a full-stack engineer, and one about a company that wasted my time in a recruitment process.
These recap articles — the ones covering December 2023 onward, including this one — are written into that site through an MCP endpoint on its admin panel, which is a thing I built so an AI assistant could research and publish my own work history. I am aware of how that sounds. It is also just true: the tool, the site, the articles and the job search are one project, and March is the month all four of them started at once.
It is worth naming what that means for the terminal work in this article. A hiring manager typing a question into the box on the front page gets an answer assembled from a hybrid retrieval pipeline I built over twelve days, drawing on a table of my own commit history including a private employer's, summarised by whichever model I could afford that week. That is either the most honest portfolio I could have built or the most elaborate, and I do not think those are exclusive.
What March was
A hundred and five pull requests across three codebases. A portfolio site taken from an empty repository to something publishable in four weeks, with a terminal front end that went from a language model pretending to be a shell to a hybrid keyword-and-embedding retrieval system over seven tables, in twelve days. Nineteen articles. Two public security disclosures. Promotion setup cut from four hours to twenty minutes. A vanilla-JavaScript control panel rewritten into SvelteKit in one weekend. A revert that lasted two minutes.
The honest summary is that March was not sustainable and was not meant to be. It was the month I decided to look for other work and then spent every available hour building the case for it, while doing the job I was leaving at full pace.
Two things I would carry forward. The first is the third pull request on the new repository — the README, written on day one. At 3cat that took twenty-one months, and when I finally did it I found a database in a pull request, a build step nobody could discover, and a password in a text file. Writing these recaps for two years did not make me better at architecture. It made me write the README first.
The second is the asterisks. When the terminal hallucinated about my work, the reason was a privacy control doing its job, and my fix was to remove the control and replace it with an instruction. That was the fastest way to a working demo and it is the weakest thing in this month's code. A guarantee you can enforce in a query should not be downgraded to a sentence in a prompt because the sentence was quicker to write.