Skip to content

Written up

Also public on GitHub

Zain Butt · London, UKEmailCVGitHubLinkedIn

Full-stack product engineer, applied AI · 2026 graduate · London, UK

Zain Butt

Selected work

A photograph is uploaded to ProofLens's public share portal and verified: the SHA-256 digest matches, the Ed25519 device signature is valid, and an RFC 3161 authority has anchored the credential
Year
2026
Role
Design and engineering
Built with
TypeScriptReact NativeEd25519RFC 3161Express

Proof-Lens

Media provenance you can verify without trusting the person who sent it.

The problem

Everything here circles one question: how do you know a record is true? This is that question with the most at stake, because the record is a photograph and the answer has to hold up in front of someone who has no reason to trust you.

A photograph proves nothing on its own. Any image can be edited, re-encoded, or generated outright, and the metadata that might tell you so is trivially rewritten. The usual answer is to trust the platform that hosts it, which just moves the question rather than answering it.

The approach

Proof-Lens binds a capture to the device that made it and to the moment it happened, then packages both into evidence that survives leaving the app.

  • Device-linked signatures. The capture is hashed and signed on the phone at the moment it is taken, with an Ed25519 keypair generated per installation, so the signature attests to a registered device rather than to a server, and it does that on an ordinary handset, without asking the manufacturer for attestation. That last clause is the contribution. The systems that solve this well mostly solve it by having the vendor cooperate.
  • RFC 3161 timestamping. An independent timestamping authority countersigns the hash, establishing that the content existed no later than a given moment.
  • Offline-verifiable bundles. Everything needed to check the claim travels with the file: a ten-file archive that verifies with nothing but OpenSSL. No network call, no account, and it still checks out if this service disappears.

The result

Evaluated over 36 named checks and seven architectural investigations against the deployed stack, not a local prototype.

MeasureResult
Credentials anchored after TSA integration124 of 124
Functional security failures under the protocolnone
Mean capture-to-anchor latency9.6 s
API surface35 endpoints, schema-validated at middleware
Mobile client~4,498 lines across 21 files

An anchored credential was then verified offline using only OpenSSL and the bundled artefacts, with no contact with the live service at all, which is the only test of that claim worth running, because a verification that phones home is not the thing being claimed.

Four capabilities landed beyond the original scope: signed audio at full parity with images, burst-mode signing where each of up to ten frames gets its own hash, signature and anchor, the offline bundles themselves, and time-limited share links whose bearer tokens are never stored.

Deletion is refused by the database rather than by the application: a BEFORE DELETE trigger on credentials, audio records, device keys and the audit tables, with the service role deliberately not exempt. Revenue OS reaches for the same mechanism for the same reason, which is the argument both projects are really making: a rule that lives in application code is a rule until someone writes a new code path.

What was hard

The interesting constraint is that verification has to work for someone who does not trust you, does not have your app, and may be offline. That rules out any design where the proof is a lookup against a service you control.

What I'd change

Device trust is an assumption, not a guarantee, and the report says so. The Ed25519 private key lives in Expo SecureStore, which is not Secure Enclave-backed attestation. If that key is extracted, the server cannot distinguish legitimate credentials from forged ones, and revocation limits future damage without invalidating anything already signed. Native hardware-backed key storage is the only item on my list that moves the root of trust rather than the reporting around it. It was scoped and then dropped: the Secure Enclave path needs a custom build, and I would rather ship the honest boundary than a claim the build could not support.

All temporal assurance depends on a single authority. Every bundle inherits FreeTSA's lifespan, and during the project its leaf certificate passed its notAfter date while the tokens already anchored under it carried on verifying OK regardless. That is the clearest possible argument for multi-TSA redundancy: the failure mode is not the authority disappearing, it is the authority continuing to look fine.

The evaluation was same-author. Two iOS devices, single-user conditions, no blind external review or third-party retest. The protocol behaviour and the offline verification workflow are evidenced, but "no one else has tried to break it" is a limitation of the evidence, not a property of the system.

I ordered the remaining work by how much it would strengthen the truth of the security claims rather than by how appealing the feature was: external adversarial review first, hardware-backed keys second, a retention and erasure policy for the GDPR Article 17 tension third. Broader device coverage and packaging come fourth, and multi-TSA redundancy last.

Last is uncomfortable, given the paragraph above, and it is still the order I would defend. The certificate expired and the archived tokens verified anyway, so of everything on the list the single-authority dependency is the one that has cost least so far. Ranking by how much each item would strengthen the truth of the claims means occasionally ranking the problem you find most interesting below the one that actually changes what the report is allowed to say.

A development render of River's Rooftop venue at dusk, eight black-tie players around the table. On hover, a hand plays out on the live 2D table at playriver.uk: bets go in around a full table and a raise is set on the arc.
Year
2026
Role
Design and engineering
Built with
TypeScriptNext.jsWebSocketsSupabasePyTorchBlender

River

Texas Hold'em for friends, where nobody has to trust the dealer.

A question this site keeps returning to, this time at a card table: how do you know a record is true? Proof-Lens asks it of a file, Melody of a filing, Revenue OS of a sent email. River asks it of a shuffle, and of every chip that moves.

This is an early v1, live since late September. What you can play today is the 2D table. The 3D venues, built in Blender, are in development, and the render above is one of them. The repository is private.

The problem

Online poker is either a mobile casino engineered to sell chips, or a console title that wants a full evening, a headset and a lobby queue. Neither lets a group of friends open one link and be dealt into the same hand.

And every online card game asks for the same leap of faith: that the server shuffled honestly. You never see the deck. You are told it was random.

The approach

The server owns everything, and proves what it can.

  • The rules are a pure engine. Every rule of Texas Hold'em lives in one package with no network, no database and no clock. It is the most heavily tested code in the project, part of more than 1,500 tests that run on every push against Node 22 and 24.
  • The server owns every chip. Clients send intentions and the server decides. Chips move only through an append-only ledger, so a balance is the sum of its history rather than a number someone can overwrite.
  • Shuffles are committed before the deal. Each hand starts from a fresh 32-byte server seed. Its hash is published before any player's seed is known, the deck is drawn from all of them together, and the seed is revealed after the hand settles. Any player can check that the deck they were dealt was the one promised.
  • No real money, by construction. Chips cannot be bought or cashed out, and no code path may create a purchase route. That keeps River out of a regulatory category it has no business being in.

The bots

Empty seats fill with bots, so a group can always get a game. They have ordinary names, and nothing at the table says which is strong.

A bot sees only what its seat could legally know. It never sees another player's cards or the cards still to come, and the server turns every decision into a legal move. Those rules came first, before any attempt to make the bots good.

The first bots were rule-based, and lost heads-up to every test style by 76 to 101 big blinds per 100 hands. Each version since has been measured rather than judged by eye. The live champion, Nell, runs a CFR+ solver on every two-player river, solving from both players' ranges, then bends the solution towards what each opponent tends to do, trusting a read only as far as the evidence for it goes. Across twelve fresh tables that beat the previous version by 24.5 big blinds per 100 hands. Against Slumbot, a competition-grade heads-up bot, she comes out about break-even over 7,231 hands: 4.5 big blinds per 100 behind, with all-ins valued at their odds. The interval runs from 45 behind to 36 ahead, so she may still be losing, and the claim is break-even, not better.

Learning the turn

Nell solves only the river exactly, because nothing can solve a whole hand of poker while it is being played. DeepStack, one of the research bots that beat professional players, got around that with neural networks that estimate what the rest of a hand is worth, so the solver can stop early and ask. That is the research half of River.

I trained value networks in PyTorch on solver output, starting from 200,600 river spots solved overnight, and built a depth-limited turn solver that uses a network to value everything after the turn. The turn network's error fell from 5.7% to 3.3% of the pot, measured against turns solved exactly over every river card, once it learned what the betting adds rather than only what the cards are worth.

The turn solver made the bot harder to exploit: a best-response opponent that knows its strategy won 42% less from it. It still lost chips to Nell at the tables. So I instrumented every hand and built an explainer for each turn decision. One fault fell out quickly, a broken handover of ranges from the turn to the river. The rest traced to one hand: the bot folding jacks full. Re-solved with exact values it still folded, because a subgame solved on its own, against an opponent assumed to play perfectly, is unsafe. That is a known failure in the research literature, and naming it decided the next step instead of another round of tuning.

This part is research. The bot at the live tables does not use a network yet.

What was hard

Discovering that "provably fair" was a claim the first version could not back.

The original shuffle drew every hand in a room from one seed, chosen when the room was created, using a generator with about four billion possible states. A deck of 52 cards has far more orderings than that, so most hands could never be dealt at all. The commitment proved the server had not changed its mind. It proved nothing about how the seed was chosen. The rule that came out of it is in the decision log as provably fair, or do not claim it.

The other hard part was the characters. More than thirty passes in Blender took the first one from a stock suit to a tailored black-tie figure, but none produced a seated poker pose that held up, so a character's pose and fit are now made by hand. The venues themselves stay as code: geometry, light, cameras and props all live in a Python pipeline, and building is the only way a venue comes into existence.

What I'd change

Next is safe continual re-solving: solving each street again from where the hand actually stands, in a way that cannot be exploited the way the turn solver was, from the flop down, with networks for the flop and before it. It goes live only when it beats Nell head-to-head on the same deals.

And the 3D venues need to reach the live table. Until they do, the strongest visual work in River is something players cannot yet see.

Method

I design River, set the rules its work is held to, and verify what ships. AI coding agents do much of the implementation under that review, and the record of it, including what went wrong, is kept beside the code.

About

Zain Butt

I build product end to end: mobile, web, and the backend underneath. Computer Science graduate, 2:1, London. Unrestricted UK right to work, looking for a graduate or product engineering role.

Everything below asks the same question: how do you know a record is true? I build product end to end: mobile, web, and the backend underneath.

BSc Computer Science, Upper Second-Class Honours, City St George's, University of London. Final-year project 83%. London, UK, with unrestricted UK right to work, looking for a graduate or product engineering role, and taking freelance work now.

Recent work: a tool that proves a file has not been altered since it was captured, a multiplayer poker game where nobody has to trust the dealer, a keyboard-first terminal for reading company filings from primary sources, and a ledger that settles group expenses without a spreadsheet. Alongside those, freelance builds for paying clients: an operations platform for a cross-border logistics team, whose client reported roughly 60% higher operational efficiency and kept me on for support, and a booking site for a private tutor who reported two- to threefold growth in enquiries.

Melody's workspace fills with a delayed AAPL quote and an SEC EDGAR company overview, then the command AAPL G is typed into the terminal bar and opens a live Alpha Vantage price chart
Year
2026
Role
Design and engineering
Built with
TypeScriptSEC EDGARNodePlaywright

Melody

A command-first research terminal for public companies.

The problem

The same question as Proof-Lens, pointed at a different record: a filing is only worth reading if you can see where it came from and how old it is. Most tools show you the number and hide both.

Research tools for public companies are built around dashboards. Dashboards are good at showing you what someone else decided was important, and bad at answering a specific question quickly. If you already know what you want, a dashboard is in your way.

The approach

Melody inverts it. You type what you want and the answer streams back.

  • Primary sources. SEC filings direct from EDGAR, not a summarised feed.
  • Delayed market data, clearly labelled as delayed. The honest presentation matters more than the freshness.
  • Command-first. Keyboard is the primary interface; the UI exists to display results, not to be navigated.

What was hard

Filing data is messy in ways that resist generic parsing: the same concept is tagged differently across filers and across years. Most of the work is in the normalisation layer that sits between EDGAR and anything a user sees.

What I'd change

The rate limiter and the cache are process-local, and that is the thing that breaks under success. The limiter defaults to an in-process store, and the provider cache falls back to a module-scope Map when Supabase is not configured. At one instance this is fine. At two, each replica keeps its own counters, so "120 requests per five minutes" silently becomes 120 per replica with no coordination, and no test would catch it, because the whole suite runs in a single process.

Moving both onto shared state is the single highest-leverage change rather than the easy one: every other weakness here is static, and this is the only one that gets worse precisely as the product succeeds.

The XBRL normalisation drops metrics silently, which is a gap against the product's own premise. It walks a preference-ordered list of concepts per metric, because companies tag the same line item differently: Apple files revenue under one concept where others use plain Revenues. If every candidate misses, that metric is simply omitted from the response; only when all five fail does it raise anything. A foreign private issuer filing 20-F instead of 10-K gets a financials panel that looks complete and is not, with nothing saying so. For a tool whose entire pitch is never hiding where a number came from, that is the wrong failure mode.

One thing I got wrong and fixed. The provider chain falls back per capability, not per provider: one source structurally declines historical bars, which is a "will not" rather than a "cannot". The first version marked every bars response degraded, so every chart carried the badge, which teaches people to ignore it on the one occasion it means something.

What I would reverse is shipping nine roadmap-shell commands: placeholder panels that opened and explained why they did not work yet. It felt honest, and it meant the help catalogue spent most of the project advertising features that did not exist, five of them blocked on paid data from the first day. They are retired now, replaced by a Planned section naming what each is actually waiting on. On a project whose whole pitch is not pretending, that is the one place where shipping something beat shipping nothing, and should not have.

The Revenue OS command centre: the next action to approve as a batch, above the qualified pool and open exceptions
Year
2026
Role
Design and engineering
Built with
TypeScriptNext.jsSupabasePostgresVercel CronGmail API

Revenue OS

A pipeline that cannot record what it cannot prove.

The third time the same question turned up, pointed at my own income: how do you know a record is true? Proof-Lens asks it of a file and Melody of a filing. Revenue OS asks it of a claim my own system makes about itself: that an email was sent, to someone who agreed to be contacted, after a human said yes.

This one is private and stays private. It runs a real pipeline containing real people's contact details, so there is no repository to read and no live link to follow. What is shown here is the part that is safe to show and also the part worth showing.

The problem

Anything that sends email on your behalf is a small machine for embarrassing you. The failure modes are not exotic: a message goes to someone who asked never to hear from you again, a retry sends the same thing twice, a batch you never approved goes out because a script read the wrong flag, or the system records a send that never actually happened and you follow up on a conversation that does not exist.

Every one of those is a data-integrity problem wearing a sales hat.

The approach

The rules live in the database, not in the application.

  • The boundary is a trigger. Before any outreach row can move toward a sent state, enforce_outreach_boundary checks it against the do-not-contact list and the suppression list, requires a linked approval record, requires that approval to actually be approved, and requires a named, approved batch before sent state can be recorded at all.
  • Sent means provable. The same trigger refuses the write without a verified Gmail message ID and a sent timestamp, and rejects anything that did not use the approved sender alias.
  • The ledger is immutable. Once a message is verified sent, lock_verified_sent_ledger makes those fields unchangeable, including the human approval timestamp and the verified thread ID.
  • Identities cannot be edited away. A do-not-contact entry cannot be altered; the only legal move is to add a separate suppression record, so the history of who asked not to be contacted is append-only.
  • Approvals expire. A batch that was approved and then left alone stops being a valid licence to send.
  • Audit payloads are redacted at write time, so the change log cannot quietly become a second copy of the personal data it is auditing.

The outreach boundary: drafted, approved, verified sent, above the exceptions the database raises

A Vercel cron job is the only execution path. It runs once a day, sends only the current London date's approved group, records lineage before marking anything sent, and is capped at twenty messages. It cannot approve, draft, rewrite or add a recipient.

What was hard

Deciding that the application layer could not be trusted, including the parts I wrote.

Every one of those guarantees started life as a check in TypeScript, which is exactly where such checks go to die: a new code path forgets one, a script written in a hurry bypasses the service entirely, an agent with database credentials does something reasonable-looking at three in the morning. Moving them into triggers means the rule holds no matter what touches the row, and it means a bug in my own code fails loudly instead of sending something it should not have.

The other hard part was accepting that an unprovable send is not a send. If the provider outcome cannot be recorded, the message is held for human review and never automatically retried, because the alternative is a system that resolves its own uncertainty by guessing, which is the failure this whole site is about.

What I'd change

The twenty-a-day cap is a number I chose rather than derived, and it is doing two jobs at once: protecting deliverability and protecting me from myself. Those deserve separate controls, because the right answer to the first is a function of domain reputation and the right answer to the second is a matter of judgement.

The approval model is per-batch, which is the right unit for sending and the wrong unit for reviewing. Reviewing twenty messages as one decision means the twentieth gets less attention than the first, and the design currently makes that invisible rather than surfacing it.

What I would build next is the reconciliation pass. The system proves what it sent; it does not yet prove that what it recorded matches what the provider believes a week later. Everything is in place for that check and it does not exist, which means the ledger is trustworthy at write time and merely probably correct thereafter.

Replay: "The shape of your time.", with a year scrubber marking the months a trip falls in
Year
2025
Role
Design and engineering
Built with
TypeScriptElectronSQLite

Replay

A local-first timeline that turns a camera roll back into trips and days.

The problem

A camera roll is chronological and nothing else. The structure you actually remember (the trip, the day, the afternoon something happened) exists in your head and nowhere in the file system.

The approach

Replay reads a photo and video library on Windows and reconstructs that structure locally. Nothing is uploaded; the library never leaves the machine.

  • Clusters captures into trips, days and moments from time and location.
  • Local-first by design: no account, no sync, no cloud dependency.

What was hard

Clustering is easy to make work on your own library and hard to make work on someone else's. Threshold-tuning that felt correct on a dense London camera roll fell apart on a sparse one.

What I'd change

The database call is synchronous, and that is not a theoretical problem. node:sqlite's DatabaseSync blocks the whole event loop for every other request while a query runs. Before I added the pairing index, the Live Photo subquery took 13.2 seconds against 2,370 assets, while the client polled it every 2.5 seconds. Splitting the reads and adding that index took the library endpoint to about 27ms, and a timeline page to 14–79ms.

Two smaller holes of the same shape. Updating an asset is a plain read-then-write with no version check and no transaction, so two overlapping edits silently lose one. And the recursive file walk has no error handling and no depth limit, while the source-root check only requires a path of three characters. Register something too broad and a single permission-denied subdirectory throws away the entire scan with no partial progress kept.

The change that matters is neither of those. It is the acceptance suite the roadmap already lists and I never built: automated proof that originals stay byte-identical after a scan, that a rescan is idempotent, and that a disconnected root cannot prune records. The whole pitch of this tool is "chronological truth, originals never touched", and that currently rests on my having been careful rather than on anything that fails a build when it stops being true. Three test files exist. The stated design target of 10,000 records under 250ms has never been verified, and the README says so.

What I would reverse is the first attempt at derivation: two environment flags gating eager thumbnailing, which the dev script never set. The indexer worked perfectly and the app showed almost nothing (2,076 of 2,375 assets had no preview) for as long as that went unnoticed. Derivation is now a second job type competing in the same poll loop, prioritised over scans in SQL.

IOU review screen: a £66 food bill split four ways with service charge
Year
2025
Role
Design and engineering
Built with
TypeScriptExpoExpressSupabase

IOU

Group-trip expenses and rounds, settled without the spreadsheet.

The problem

Splitting costs on a group trip is a solved problem that keeps being solved badly. Most tools handle the arithmetic and ignore the social part: nobody wants to be the person chasing four friends for £11.

The approach

A shared ledger for trips and rounds, with the settlement maths done continuously rather than at the end.

  • Full stack across mobile and backend: Expo client, Express API, Supabase.
  • Rounds modelled as first-class objects, not just another expense category.

What was hard

Bills and rounds are two different financial models, not two icons on one form. A bill settles real money and feeds the settlement maths; a round never touches that ledger and only ever produces a social nudge. Every other splitting app collapses the two. Doing that would mean either overstating what people owe or diluting what a bill means. A round is a social contract with fuzzy fairness, not a debt.

The other half was currency. Every row stores its original amount, its base-currency amount, and the rate that converted them, locked at creation, because a trip runs for a week and rates move daily, and re-deriving on read would let a Lisbon bill from day one quietly reprice itself by day seven.

What I'd change

The blanket claim I nearly published here was wrong, so here is the narrow one. Every mutation that changes shared state (claiming a profile, completing one, soft-deleting or restoring a bill) is a Postgres SECURITY DEFINER function that opens with SELECT ... FOR UPDATE on the target row. That is a deliberate lock, not the default; the usual shape is a client-side update() straight from the Supabase client. Two people tapping "claim" on the same profile within a second is not hypothetical in a five-person group chat.

The gap is the one endpoint where it mattered most. Trip finalisation reads the trip status, computes settlements, deletes and re-inserts them, then flips the trip to locked. None of it is in a transaction or behind a row lock, and the settlements table has no unique constraint on the member pair. A double-tap on "End trip" passes the status check twice and writes duplicate settlement rows on the one screen where the numbers are meant to be final.

So the fix is one Postgres function shaped like all the others: lock the trip, compute, write, flip the status, in a single transaction. It is the single change rather than the easy one because it closes the distance between what this codebase's own pattern says finalisation should look like and what it actually does. The easy change would be pagination.

Then the unglamorous three. Nothing paginates: every bill, round and message for a trip loads in full on every open and after every mutation. FX conversion is a single uncached external call with no way to enter a rate by hand, so a cold start at the provider blocks every non-base-currency bill outright. And there are no tests at all, on money maths that is pure functions and needs no UI to exercise.

What I would reverse is the single 2,635-line schema file meant to be pasted into the Supabase editor by hand, with three rounds of later changes appended as comment-delimited sections. It worked while I was the only one running it, and stopped being defensible the moment I wanted CI or a rollback story. It is now three timestamped migrations, verified byte-identical against the original.

Accuracy for three models on the same four age classes: HOG+SVM 0.7106, HOG+MLP 0.7294, ResNet18 0.7871
Year
2025
Role
Research and implementation
Built with
PythonPyTorchscikit-learnResNet18

Age Group Detection

Three approaches to the same classification problem, measured honestly.

This one has no interface and was never meant to. It ran from a notebook: a demonstration rather than a product, included because the method is the point.

The problem

Four-class age-group classification, which is less a modelling problem than a comparison problem: the interesting question is not whether a deep model beats a classical one, but by how much, at what cost, and on which failure cases.

The approach

Three pipelines built to be compared like for like.

  • HOG + SVM: the classical baseline.
  • MLP: a shallow learned representation.
  • ResNet18: transfer learning from a pretrained backbone.

Identical splits, identical preprocessing, identical metrics.

The result

ModelAccuracyMacro F1InferenceSize
HOG + SVM0.71060.70080.02 ms0.10 MB
HOG + MLP0.72940.71810.02 ms10.41 MB
ResNet180.78710.779619.91 ms42.72 MB

ResNet18 wins by roughly eight points over the classical baseline, and pays for it with a thousandfold increase in inference time and four hundred times the model size. Whether that trade is worth making depends entirely on where the thing has to run, which is the answer the comparison existed to produce.

What was hard

Class imbalance across the age bands, and resisting the temptation to report the accuracy figure that flattered the model I'd spent the most time on.

What I'd change

The comparison is sound and the reporting around it is thin. Accuracy and macro F1 across four classes hide the thing that matters most: which classes each model confuses, and whether all three fail on the same faces. A confusion matrix per model would have said more than any of the headline figures, and it is the first thing I would add.

The repository reports no per-class breakdown and no treatment of the class imbalance beyond acknowledging it. That is the real gap. Macro F1 is chosen precisely because it does not let a majority class carry the score, but choosing a fairer metric is not the same as addressing the imbalance, and I would not want the metric to be mistaken for a fix.

The largest omission is bias. Age classification from faces is known to perform unevenly across skin tone and gender, and I did not measure it. Any conclusion here about which model is better is a conclusion about aggregate accuracy on one dataset, which is a much narrower claim than it looks, and on this particular task, the narrower claim is the only one worth making.