Skip to main content

Engineering notes

The version with the wiring showing.

The front of this site is written for people who hire. This part is written for you: the permission model, the audit I ran on myself, and what four languages taught me about one product. Written for people who’ll argue with it.

Fair warning about what this is: one person’s production system, described honestly, including the parts where the honest description is “this was wrong, and here’s how I found out.” If you’re going to interview me, this is the page to bring notes from.

And if you don’t write software: the front page wasn’t a simplified version of this one. It was the honest summary, and you can stop there without missing anything.

Acronyms are permitted below this line.

01 · orientation

The shape of it.

Front end
Angular 22, standalone components throughout (no NgModules), signals for auth state, functional route guards, a permission-gated nav. Built to content-hashed static bundles, served by nginx.
API
ASP.NET Core on .NET 10, controller-based. 24 controller classes across 27 files, 80 endpoint attributes. Controllers bind, delegate and map; none of them touch the DbContext.
Data
PostgreSQL 17 via EF Core and Npgsql. 32 migrations, applied on startup. DbContext pooling on the hot path.
Identity
A Google OIDC token verified server-side, then exchanged for an app-minted JWT in an HttpOnly, SameSite cookie, so page scripts never see it. Accounts keyed to Google's immutable subject rather than the email; a subject mismatch on an existing email is rejected outright rather than quietly merged.
Authorization
14 permission keys in a typed catalog, re-read from Postgres on every request behind a [RequirePermission] filter. An implication graph (manage implies view; locations.view.all implies locations.view) is expanded once at resolution, so the API, the /me payload, the route guards and the menus cannot disagree.
Ownership scope
locations.view returns your own rows, locations.view.all returns everyone's, enforced by a VisibleTo query extension rather than by hand at each call site, so a new endpoint inherits the scope instead of forgetting it.
Real-time
SignalR, groups joined per permission, with a per-device session registry and a revocation sweep so a disabled account loses its socket rather than keeping it until reconnect.
AI
Four providers behind one abstraction (Gemini, OpenAI, Claude, and a deterministic Fake) under versioned, live-editable entry points.
Ops
Docker Compose locally; Terraform and AWS in production; GitHub Actions with OIDC deploys and no long-lived keys.

02 · mechanism

The token says who. The database says what.

Google returns an ID token; I verify it and mint my own JWT, which goes into an HttpOnly SameSite cookie — the SPA never holds a raw token, so there’s nothing for a cross-site script to read. The account is keyed to the Google subject rather than the email, because emails get recycled and subjects don’t.

The JWT carries identity and nothing else. No permission claims. Effective permissions resolve from Postgres on every request behind a [RequirePermission] filter, so a revoked grant takes effect on the next request rather than at token expiry. Yes, that’s a read per request. It’s indexed, it sits behind a pooled DbContext, and I’d pay it again.

Two guards worth naming because they’re exactly the kind that get deleted in a refactor. A grant ceiling: you cannot grant a permission you don’t hold yourself, so there’s no self-escalation path through the role editor. And a last-admin guard: the system refuses the operation that would leave it with no administrator.

Session state is per-device and revocable. Revocation is checked in OnTokenValidated, so killing a session kills its token immediately. On the SignalR side the connection registers and then re-checks, so a role change or a disable aborts a live socket instead of waiting for a reconnect. Both are pinned by tests that were mutation-verified — I deleted the check, watched the specific test fail, and put it back.

Google verifies the personAn ID token, checked server-side. No password ever reaches me.
I mint a token that says only whoHttpOnly, SameSite cookie. No permission claims inside it, ever.
Every request asks the database whatEffective permissions resolved fresh, behind one filter attribute.
Revoke at 2:00 and they are out at 2:00Next request, not next token expiry. That is the whole trade.
The request path, drawn as a diagram rather than screenshotted. The cost is one indexed read per request; the return is that revocation is immediate.

03 · mechanism

Three append-only stores, and one queue — in front of the request log.

The three stores are written on the request path, inside the same transaction as the change they describe. An audit row that can outlive the mutation it records is worse than a slow one — so the record and the change commit together or neither does. Three separate stores, because they answer different questions: an action audit (what happened), field-level change history (what the value was before), and an activity timeline (what a given person has been doing).

What does ride a bounded channel is the request log, drained by a background writer, so nothing a user is waiting on blocks on that. Bounded is the interesting word. An unbounded channel turns a logging stall into a memory leak; a bounded one turns it into a decision I had to make on purpose, which is that a saturated channel drops request-log entries rather than user work — and it counts what it dropped, because silent loss of a log is itself a finding.

The honest failure mode, and it is on the request log rather than the audit trail: a hard process death loses whatever entries are still in the channel, where a graceful stop drains it. The audit trail pays the other price instead — the write is on the user’s time, not a worker’s. That is the trade I’d defend, and under a stricter regime the thing I’d revisit is the request log, not the audit.

Retention is a scheduled job rather than a manual chore: 365 days for audit, change history and activity; 90 for sign-in events, request logs and ended sessions; 30 for notifications, location pings and the AI run log. An ErasureService scrubs personal data out of most of the append-only stores by subject — the AI run log is the documented exception, and it ages out on its own window instead. That’s the awkward part of append-only that nobody mentions — “immutable” and “right to erasure” have to be reconciled somewhere. Here that somewhere is a scrubbing pass that preserves the shape of history while removing the person from it. Location and AI-processing consent are captured with timestamps and are revocable.

Request logging is redaction-aware: /api/auth is redacted, and so is the hub’s access-token query parameter — which I only fixed because an audit pointed out that a recorded token would hand a logs.view holder a replayable admin JWT. Ordinary query strings are deliberately NOT redacted, and there’s a test asserting that, because a log that redacts everything is a log nobody reads.

04 · the one I got wrong

The wire is a boundary, and I forgot.

SignalR groups are joined per permission, so a live push honours the same authorization as the REST route serving the same data. That’s the invariant. Here’s how I broke it.

The ownership split gives locations.view your own pings and locations.view.all everyone’s, and GET /api/location scopes reads through VisibleTo on locations.view.all — so an own-scope holder correctly sees only their own rows there. NotificationService.PushLocation was targeting the own-scope PermissionGroup(locations.view), which own-scope-only holders join, including the seeded Manager role. So a Manager with the app open received every sharing user’s precise coordinates over the socket, live, while the REST endpoint returned only their own and the router bounced them off the all-locations screen.

The API said no and the wire said yes.

It was my own regression: I made Notify permission-aware when I added targeted delivery and left the location push on the wrong tier. Rated HIGH by the review.

The push now targets the locations.view.all group plus the owner’s personal group, so their own live map still updates. The client keys markers by ping id, so an owner who also holds view-all receives two copies and the second is a harmless duplicate. The tests assert the group membership and fail if the push is ever widened again.

Why it happened, which generalises further than the fix does — authorization was implemented twice, once in the query layer and once in the hub, and only one of them was ever reviewed as authorization. Any time a rule exists in two places, one of them is unreviewed until you go looking. Every real-time layer is a second authorization surface and it will drift from the first unless something forces them to agree. If you take one thing from this page, take that.

A known limitation I haven’t closed Groups are joined at connect time, so revoking a permission while a socket is open doesn’t remove that connection from the group until it reconnects. Register-then-recheck narrows the window; it doesn’t close it. It’s documented rather than forgotten, which is the distinction I care about.

The rest of the audit

05 · the platform

AI features as configuration, versioned like schema.

Each capability is a named entry point with a code-declared contract. Swap the provider, rewrite and version the prompt, gate it by role, and watch every run — all live, no redeploy. This is the part I’d most like to be asked about in an interview.

The catalog — an entry point is declared in code with JSON input and output schemas and a sample input. The catalog merges each declaration with its live config row and today’s call and cost figures. A keyless deploy stays inert by design: nothing auto-invokes.

Live config — an AiEntryPointConfig row overrides the code default wholesale: provider, model, reasoning level, temperature, system prompt. The model is validated against the database catalog rather than a static seed, so adding a model isn’t a release. Two typed per-call knobs shape the prompt itself rather than the sampling: MaxItems caps how many items a batch entry point folds in and recomputes the count its template renders, and ExtraInstructions is appended to the system prompt. That distinction matters — the prompt-shaping knobs take effect even on the keyless Fake, where the sampling knobs cannot.

Versioned prompts — editing a system prompt or template cuts an immutable AiPromptVersion and repoints ActivePromptVersion. Rollback is re-activating an earlier version. Concurrent cuts resolve to a 409 rather than a lost write — meaning two people editing at once get “someone else changed this, try again” instead of one of them silently winning. That wasn’t true until an audit round went looking for check-then-write races.

Access control — an allowed-roles list gates invocation; empty means everyone. An unknown role name is rejected rather than silently dropped, because the silent-drop version of that bug quietly widens a restricted capability to every signed-in user and looks like nothing happened. Worth naming, because “unrecognised value ignored” is the default behaviour of most validation and it fails open.

The run log — every invocation records provider, model, tokens, cost, latency and status with redacted request and response snapshots, filterable by entry point, status, date, model, cost or actor. Secrets never enter the log.

The honest dry run — the test panel invokes through the same gateway production uses, identical code path, with only the allowed-roles gate bypassed for an ai.manage admin. The usage line surfaces the requested reasoning level, and flags in amber whenever the deterministic Fake answered — either a keyless fallback (status NotConfigured) or a Fake-by-default entry point. It then says explicitly that the model, thinking level, temperature and max-tokens were not applied. It exists so a keyless demo can never imply that those knobs took effect. If you want the one design decision I’d defend hardest, it’s this one: the feature whose entire job is to make my own demo look worse.

Run it yourself, on the deterministic Fake — no sign-in

AiEntryPointConfigActivePromptVersion → v3
providerGemini | OpenAI | Claude | Fake
modelvalidated against the DB catalog, not a seed
reasoningsampling knob
temperaturesampling knob
maxItemsprompt-shaping knob
extraInstructionsprompt-shaping knob
systemPromptedit cuts an immutable version; rollback re-activates
allowedRolesempty = everyone · unknown name = rejected, never dropped
concurrent edit409, not a lost write
The configuration surface of one entry point, drawn as a diagram — real field names, no invented data, and not a screenshot. The blue two are the knobs that still take effect when the keyless Fake answers; the sampling knobs cannot, and the console says so in amber.

06 · the experiment

Four backends, one product, one variable.

What stayed constant, what didn’t, and what I’d reach for next time. Two tables, kept apart deliberately: the first is judgement, the second is measured, and the method that produced the second is published with it.

This is the short version: two tables and four verdicts. The experiment itself has its own page — every measured column with the method beside it, the environment and the caveats, where the languages changed nothing at all, and the list of what has not been run. It keeps absorbing new match-ups, so it is the one that will still be current when there are more than four.

Held constant — a React and TypeScript SPA on Vite. SSO keyed to the provider’s immutable identity. DB-backed roles and permissions enforced on every request. Live presence with a one-click session kill-switch. MySQL 8.4. Docker Compose. Varied — the backend language and runtime, and nothing else.
Four builds of the same access-control console, compared by language, the toolkit it runs on, what each one was best at, and what it cost. The last two columns are judgement — what building and running each one was like. The measured comparison is the second table, below.
ImplementationLanguageToolkitWhat it was best atWhat it cost
KingfisherPython 3.13FastAPI 0.139Fastest to a working endpoint. Async throughout, durable security events, a terminal-styled event stream I regret nothing about.The original, and therefore the one carrying every early design mistake. The one where I leaned hardest on tests to replace what a compiler wasn't telling me.
SharpbillC# / .NET 10ASP.NET CoreEasiest to change six months later. Cleanly layered — contracts, domain, application, infrastructure, workers, middleware — plus Playwright end-to-end tests, SBOM generation, Trivy scanning and CODEOWNERS wired into CI. The one I'd hand to a team of five without worrying.The most ceremony of the four, and the slowest to first light.
GoldfinchGo 1.26Chi 5.2, database/sqlSmallest image, fastest start, fewest surprises in production. No ORM, no framework acting on my behalf. Google and Microsoft OIDC verified server-side, presence over WebSockets with a polling fallback, retention and erasure controls, one small container.No framework acting on my behalf means every mechanism is mine to write and mine to keep working. It felt like the most code of the four. It was not.
MerlinTypeScriptNestJS 11, published OpenAPI v1TypeScript from the database driver to the browser, the same RBAC on every protected request, and the identical React SPA on top. Genuinely pleasant.Right up until you want a guarantee the type system can't give you.
The measured half, taken on 2026-07-27. One machine, one shared MySQL 8.4, each repository’s own production Dockerfile unmodified, one server process each. Lines of code counts hand-written application source and excludes tests, migration content and generated files. Cold start is the median of ten runs from the container starting to its first successful health response, with the container tooling’s own overhead excluded. p95 is the 95th percentile of 500 requests at concurrency 4 against /api/auth/me, the permission-check endpoint all four expose at that exact path. The full method, the environment, the caveats and the per-run figures are in four-builds.json.
ImplementationLanguageLines of codeImage sizeCold startp95, permission check
KingfisherPython7,200664MB518.3 ms52.3 ms
SharpbillC#20,226263MB276.5 ms4.7 ms
GoldfinchGo14,94262.4MB84.1 ms2.6 ms
MerlinTypeScript12,775490MB581.5 ms9.7 ms

Where the measurement changed my mind — this table used to say Goldfinch cost “most lines of my own code, by a distance.” The count says Sharpbill is the largest of the four, with about five thousand lines between them, so I rewrote the verdict. It was wrong in the direction vanity would predict: writing every mechanism by hand feels like volume, because you watch each line arrive, while a framework writes just as much and shows you none of it. I would have defended that sentence in a room and I would have been wrong. The old wording is quoted here on purpose — a corrected page that hides what it corrected is just a page claiming it was never wrong.

What the p95 column is actually showing — the permission check takes its row lock on the same session row and writes a presence touch, in all four, by design. So four concurrent callers contend with each other on purpose. Measured one at a time instead of four, the spread narrows sharply and the Python build in particular is a different animal — the figures for both are in the JSON. The honest reading of that column is not “this language is slow”; it is that under contention on an identical query plan, the runtimes diverge by more than an order of magnitude, and one of them gets slower when you give it more concurrency.

The finding that doesn’t need a number — the authorization layer was the hardest part in all four, and it was hard for the same reasons every time. None of them were language reasons. Modelling who may do what to whom is where the difficulty lives, and switching runtime doesn’t move it.

What I’d actually pick — depends on who maintains it after me. Sharpbill for a team. Goldfinch if it has to be small and boring and start instantly. Kingfisher if the requirements are still moving. Merlin if the front end is the product. If you’re hiring for one specific stack, the useful question isn’t which of these I like — it’s what transfers, and the answer turned out to be almost all of it, which was not what I expected going in.

Each one went through the same review this codebase gets: parallel lenses over the permission model, every finding handed to an independent skeptic told to refute it from the source and to default to refuted when uncertain, repeated until a round comes back empty. It is pointed at privilege escalation specifically, because that is where a permission bug stops being embarrassing and starts being a breach. Those four repositories are private, so you would be taking my word for what it found in them — the next section is the same process run against a system I can walk you through line by line.

07 · findings

Four adversarial rounds against my own code. Here’s what they found.

Converged clean on the fourth. The first three did not.

Method: parallel review lenses, each finding handed to an independent skeptic told to refute it by reading the real code and to default to refuted when uncertain. The refutations mattered as much as the confirmations.

The live location leak

Above, in full. My own regression, rated HIGH, fixed and pinned by tests that fail if the push is ever widened again.

A token that could have been written to the request log

The hub accepts the session token as a query parameter, because a WebSocket can't set an Authorization header. Redaction covered /api/auth only, and the logs controller serves the query string verbatim to any logs.view holder, so a recorded token would have handed a Manager a replayable admin JWT. Nothing leaked, because our own client authenticates the hub by cookie; this closed the door before a future mobile client or integration started using the parameter the endpoint openly supports.

Five concurrency races

Check-then-write paths that could interleave now return a clean 409 instead of a raw 500. In plain terms: a genuine conflict says "someone else changed this, retry" rather than failing with an error that tells the caller nothing and loses their work. Unglamorous, and exactly the class of bug that only appears once you have more than one user.

Three untested guards

They were correct; nothing proved they'd stay correct. Each now has a test written to fail if its guard is deleted, rather than merely to pass today. A test that passes for the wrong reason is worse than no test.

Zero API tests asserted an email was absent from a response

The front end had twelve such assertions and the back end had none. So the display of the boundary was well covered and the boundary itself was unguarded. That's the most damning single finding of the four rounds, and it's mine.

Round three caught two of round two's own fixes being too broad

A blanket exception catch that mis-mapped a deadlock or a connection failure to a false "already exists" conflict, hiding the real and often retryable error. And a single-retry path that could still fail under a third concurrent writer. Fixing something badly is its own finding.

And the refutations

The review claimed session versioning was broken. It wasn't; the production code was correct and the test was the gap. It claimed the domain allowlist was broken: correct code, missing test. And it filed several dead-code reports that were unused theme options rather than defects. An audit you don't push back on isn't an audit.

A finding I closed by deciding, not by coding Continuous sharing used to write a persistent notification per ping — about 120 rows an hour per stationary sharer — which buried every real notification in the bell. The live-map push was correct; the persistent notify was not. The fix was a product decision rather than a bug fix, and on 18 July 2026 I took it: drop the per-ping notification entirely, administrators watch the map, and a test now fails if a ping ever writes a notification row again.

Four rounds, and the fourth found nothing new. That’s the only reason I’d call it finished, and I’d still rather someone else looked.

The round-by-round report is written up in the repository, next to the code it criticises. It isn’t published anywhere and I’m not going to pretend otherwise: what you’re reading is the summary of it, the findings above are the ones that survived a skeptic, and the file itself is a screen-share away.


How it got here, in four phases.

01Foundation and identityAuth, permissions, the shape of the app
  • Shipped Google sign-in exchanged for an app JWT in an HttpOnly cookie
    Accounts pinned to the immutable Google subject, not the email.
  • Shipped Role-based access behind a typed permission catalog
    Re-read from Postgres on every request, behind [RequirePermission].
  • Hardened Grant ceiling
    You can only grant a permission you hold yourself — no self-escalation.
02Audit, real-time and AIThe platform layers
  • Shipped Append-only audit and field-level change history
    Written in the same transaction as the change; three separate event stores.
  • Shipped Live presence and notifications over SignalR
    Per-permission groups, so the wire matches the API. Mostly.
  • Shipped The AI entry-point platform
    Live-editable, versioned prompts across four pluggable providers.
03Controllers to servicesThe clean-up nobody sees
  • Shipped All business logic pulled into interface-backed services
    Zero controllers touch the database — they bind, delegate, map.
  • Hardened DbContext pooling
    Reuses context instances to cut per-request allocation on the hot path.
  • Hardened Middleware and indexes reviewed
    Non-blocking request logging; every hot-path lookup indexed.
04Adversarial self-reviewFour audit rounds, converged on the fourth
  • Found + fixed A live-GPS leak over the socket — found and fixed
    Own-scope holders were receiving everyone's coordinates on the live feed. The wire now matches the REST authorization, and a test fails if it is ever widened again.
  • Found + fixed Five concurrency races hardened
    Check-then-write paths now return a clean 409 rather than a raw 500.
  • Hardened Three untested security guards now covered
    Each test is written to fail if its guard is deleted, not merely to pass today.

08 · counted

The numbers, and how I got them.

Not typed — generated. The build recounts every one of these from source and stops if a page still says otherwise. Hand-typed counts start lying within about a month. Ask me how I know.

80
endpoint attributes across 24 controller classes (27 files — one controller is split into four partials).
From the [Http*] attributes in src/Api/Controllers.
14
permission keys in the typed catalog.
From the Catalog array in src/Api/Auth/Permissions.cs. An earlier version of this site published 12 — locations.view.all and resume.download were added after that copy was written, and nobody noticed for months. That's why this page exists.
32
migrations.
From src/Api/Data/Migrations, excluding designer and snapshot files. An earlier version of this site published 24.
4
AI providers — Gemini, OpenAI, Claude, and a deterministic Fake, all behind one interface.
From the provider implementations behind the gateway interface; the abstract HTTP base they share is not one of them.
26
application services, and the noun is doing the work: the folder they live in holds 32 files.
Counted to a definition rather than to a folder — a concrete class in src/Api/Services that the API registers in its dependency-injection container. The rest of the folder is interfaces, mappers, static helpers and a seeder, which are not services however much a file count suggests otherwise. An earlier version of this site published 21 without saying what it was counting; that, rather than the arithmetic, is what this row is fixing.
652
tests executed on the last run of the suite, all of them green.
Not counted from source — this is the total `dotnet test` printed, recorded by the build and re-checked against the test files on every run. Three figures used to disagree here and this is the only one that cannot be argued with.

Every figure above is generated. npm run counts recounts them out of the repository — the attributes, the catalog, the migrations folder, the container registrations — writes them to a committed record with the method beside each one, and the build fails if a page disagrees with it. The test total is measured rather than counted: the suite runs, the runner’s own summary line is recorded, and adding or removing a test stops the build until it has been re-measured.

That is not architecture for its own sake. Every one of the six numbers the front page of this site used to carry had gone out of date before anyone noticed — the permission catalog had grown from 12 keys to 14 and the migrations from 24 to 32 — underneath a headline claiming every number had a command behind it. It did, once. Stale precision is a worse credibility problem than no precision, so the numbers stopped being copy.

If one of them is load-bearing for you, put 30 minutes in the calendar (opens in a new tab) and I’ll run the whole thing while you watch.

09 · in hindsight

Three things I’d do differently.

I’d put the ownership model in from the start. Retrofitting “you see your own, they see everyone’s” into a system that assumed one flat permission per resource is how the live-location bug happened. The split is right; it should have been the original design. The soft-delete half of that same framework is built and unit-tested and deliberately not rolled forward, because I didn’t have an honest target date for it. Shipping half a migration on a maybe is how you get two schemas.

I’d write the boundary tests before the boundary code. Twelve front-end assertions that an email wasn’t displayed, zero API assertions that it wasn’t returned. I’d tested the display of the boundary and not the boundary.

I’d have decided about the notification flood when I made sharing continuous, not months later. Turning a one-shot code path into a continuous one and leaving the per-event notification in place is a five-minute thought I didn’t have at the time.

Come argue with me

Argue with me about any of this.

Especially the parts you think are wrong. I’ve been my own only reviewer for a long time, and it shows in ways I can’t see from in here: four adversarial rounds are still four rounds run by the person who wrote the code, and the ledger above is the whole of what that catches. A fifth reader is worth more to me than a fifth round.

The repositories are private — some of what’s in them is a real company’s operations — so “go and read the code” isn’t an offer I can make, and I’d rather say so than leave you clicking. Two things need nothing from me: the AI console runs in your browser with no sign-in, and this page is the mechanism at the depth I’d defend it at in an interview. For anything past that I’ll share a screen and walk you through the real thing, including the commit where I broke my own privacy boundary.