← Story
Chapter 4April — July 2026

EduQate, part one — the first three months

In April 2026 I started building JoyPix's multi-tenant applications platform, alone, from a short visual pitch. I own all of it: the architecture, three web apps, a mobile app, the infrastructure, the security design, and — this is where the story actually lives — every debugging session in between.

The product is EduQate, a school-admissions platform, live at eduqate.qa. The technical work was never about the domain, though. A rate limiter that counts the wrong thing is not an industry-specific problem. You'll see.

I kept a dev log for this project — decisions, a timeline, and a running list of every bug worth remembering — and this chapter and the next are basically me retelling it in a voice that isn't "note to self at 1am."

Day four: a decision from a conversation

The first real architectural fork was multi-tenancy, and I want to be honest about how it got decided, because it's not how these stories usually go.

Nobody handed me a spec that said "multi-tenant." What happened is that during planning, the person running the project mentioned — offhand, in a sentence, not written anywhere — that the platform would stay on our own server and that he wanted to get a lot of organisations onto it eventually. That's it. That's the whole input.

I turned that into an architecture commitment: every row scoped by a tenant id, from the schema up. And then I did the thing I learned at SiccaDev — I went back to him and said "here's what I think you meant, here's what it costs to build it this way, confirm I've got this right" before I built anything on top of it.

That was the right move. Everything else in this project has changed shape at least once. Forms got re-specified twice in four days at one point. The tenancy model has never moved. The difference is that I checked the interpretation while it was still cheap to be wrong.

Day one, actually: .env committed

Let's get this out of the way. .env went in with the first few commits and came back out the same day. .gitignore before anything else on a new project. I know. Everyone knows. Everyone does it once anyway.

Three weeks in: the session that wouldn't end

The initial schema had been in for about three weeks when I noticed — through using the app, not reviewing it — that an account which should have been locked out was still wandering around on a session that by every rule shouldn't have been valid.

I found it by putting two things side by side: what the frontend believed about the auth state, and what the backend logs actually showed. They disagreed. The app thought the account was logged out. The backend was cheerfully still accepting its token.

Obvious in hindsight, the way these always are. JWTs are stateless. Once you sign one, you can't take it back. The token has no idea the account got locked five minutes after it was issued, and nothing in my code was checking.

The fix is a token_version counter on each user, baked into every token and compared on every request. Bump the counter, every outstanding token for that user dies at once. No blacklist to maintain, no growing table of revoked tokens.

The lesson I actually took: decide how a session dies before you write the login endpoint. It's tempting to treat "revoke early" as an edge case for later. By the time you notice it's missing, it's because something already walked through the gap.

The connection pooler that quietly lied

This one still bothers me.

I added pgBouncer because per-request Postgres connections were not going to survive real load. Reasonable. What I hadn't fully internalised is that pgBouncer's transaction-pooling mode reuses the same underlying database connection across different client sessions. And some session-level settings — search_path, specifically — don't reset when the connection gets handed to the next client.

So: session A sets a search path. Session B inherits that connection, doesn't reset it, and now session B's queries are reading and writing against the wrong schema. No error. No crash. Just... wrong data, quietly, in the wrong place.

It got some seed data into a genuinely wrong state before I understood what was happening. I traced it back by actually learning how the pooler reuses connections under the hood, rather than treating it as a transparent performance layer that I could ignore.

The fix was to stop relying on anything session-level for scoping. And then, because I never want to rediscover silent data corruption by accident again, I built a CI job that runs the entire stack through Docker and specifically hammers queries through pgBouncer, so this exact failure mode gets caught before it ships.

Infrastructure that sits invisibly in the request path — poolers, proxies, caches — deserves to be tested through directly, not trusted to be transparent. The bugs that hide in that layer don't announce themselves. They just return the wrong thing.

Docker sprawls, because every individual decision was fine

At some point I looked up and there were nine Dockerfiles.

No single bad call. The backend needs its own Dockerfile — fine. Dev needs different build args than prod, so add a .dev variant — fine. Now repeat that across web, admin, super-admin, mobile and backend, and you have nine files, and any shared build change means editing half of them and hoping you got them all.

It's down to two now — one backend, one frontend — with build args selecting which app. The three frontend apps share almost their entire pipeline and never needed separate files. This is all downstream of the same root mistake: I never designed the infrastructure shape up front. I grew it one concrete need at a time — Docker, then Redis, then pgBouncer, then Caddy, then Cloudflare, then eventually a Rust image service — and each addition was locally sensible and the total was a mess. If I did this again I'd lock the infra shape down early even under the same time pressure. It's the thing I'd change most.

Two machines, one branch, 46 files of merge-conflict scar tissue

Around the shadcn/ui migration — which was two days and carried dark mode, a design refresh, and that Docker consolidation all at once — I paid for a bad habit. I was working the same branch from two machines, a Windows one and a Linux one, without disciplined pulls. The bill came due as one commit that does nothing but fix leftover conflict markers across 46 files.

That's not a bug, it's a process failure, and I logged it as a habit to fix rather than a thing to clean up. (The Windows/Linux split kept biting me in smaller ways for months — shell entrypoints with CRLF line endings that sh refuses to run, mobile npm scripts written in Windows set VAR=&& syntax that won't run on Linux. Two machines, one repo, endless small taxes.)

The design sweep, or: I called my own product AI slop

By mid-July I did an honest audit of how the product actually looked, across all four apps, and the diagnosis was uncomfortable: it looked like every AI-era template. Stock photos of smiling families. Icon-grid "features" sections. Interchangeable SaaS taglines. And — the real low point — a marquee of hardcoded fake organisation names scrolling across the mobile welcome screen as if it were real data.

Two things came out of that audit that I still use.

First, an inversion. I went in expecting to rip out the design system. Instead the audit found that the single strongest thing in the entire codebase was the part I'd half-forgotten building: the motion layer. Gyroscope-reactive geometry on mobile that tilts as you move the phone. Scroll choreography on web. A particle celebration animation. All token-driven, all reduced-motion aware. The genericness wasn't in the design system — it was in everything wrapped around it. So the first decision was: the motion and token layer is untouchable, and the sweep's job is to strip the junk around it and push the good system into the screens that barely used it.

Second, and more transferable: I wrote the anti-emptiness goals as checkable rules. I work solo. There's no design reviewer. "Make it feel less empty" as a vibe drifts straight back to where it started within a week. So instead: every card needs a tinted surface, a border, a three-part content structure, and one accessory element. Every list row needs a leading visual, two secondary facts, and a trailing status chip. Every screen opens with an anchor element before any filter bar. Fabricated content is banned outright, deleted on sight. When there's no reviewer, the checklist is the reviewer.

The audit also turned up something genuinely funny in a bleak-in-the-boat way: almost everything the redesign needed was already installed and buried. An entire interactive-map library, sitting in the web app behind a toggle nobody would ever find. Native map and geolocation packages on mobile, installed, unused. That finished celebration animation — built, polished, never mounted on a single screen. The gap between what the codebase could do and what the screens showed turned out to be most of the redesign. Before you design anything new, go inventory what you already built and forgot about.

July 14: I stopped trusting my own code review

This is the turning point of the whole project, so it gets its own section even though "I changed my process" is not a thrilling sentence.

My habit had been: read the code, confirm it compiles, confirm it looks right, check the flow off as done. I replaced that with a rule and a ledger. Nothing counts as done until it has been driven end to end, as a real user, in the actual running app, with the specific thing I clicked and the specific thing I saw written down against a date. Touching shared code automatically downgrades every flow that depends on it back to "unverified," because a fix over here has a way of breaking something adjacent over there.

The first time I ran this pass over a slice of the app I'd have bet money was solid, it turned up eleven-plus real bugs. Every single one was invisible to a careful code read. Here are three:

  • A response-serialization bug that had corrupted every precise decimal number in every API response — every fee, every payment, every installment — into a library's internal representation. Every endpoint still returned HTTP 200. You could only see it by reading an actual response body. (Full story in the next chapter — it's a good one.)
  • A verification-link race that flashed a scary red error at users after successfully verifying their account, because the confirm endpoint fired twice per page load and the handler wasn't safe against that.
  • A stale IP address in .env that guaranteed Google login would fail the moment I tried it — for reasons that had nothing to do with the OAuth code, and everything to do with Google refusing to accept a LAN IP as a redirect URI.

None of those fail a typecheck. None of those fail a "does the request succeed" test. All of them show up the instant you actually run the thing as the person who's supposed to use it.

I got called out, mid-session, for a related sin: I'd been trusting my own regression tests, and my regression tests only covered flows I'd already found and fixed once. That's not testing. That's a museum of past bugs. The call-out was correct and I've never forgotten it.

This is how I want to work by default now, not a one-time exercise. The next chapter is basically a highlight reel of what that discipline caught.