Mohamed Sahnoun, Data Engineer

Mohamed Sahnoun

Data Engineer

I build data pipelines end to end: batch and event-driven ingestion, SQL modelling at scale, orchestration, and the tests that make the output trustworthy.

Who is this

I am a data engineer. I take fragmented source systems and turn them into modelled, tested data that people can query without second-guessing it: ingestion (batch and event-driven), transformation in SQL and Python, orchestration, and the infrastructure as code underneath. Mostly on Google Cloud so far, though the parts that matter, modelling, idempotency, testing and cost, travel between warehouses.

Since January 2025 I have built and owned the customer data pipeline for a US fashion house, through a consultancy. Nine source systems in, one star schema out, plus the Terraform that provisions it and the assertions that stop bad data reaching it. The piece I am proudest of is a deterministic master data management engine: 7 million fragmented customer records clustered into golden IDs by a Union-Find graph algorithm written entirely in SQL. The hard part was not the volume, it was expressing graph connectivity in set-based SQL and keeping it deterministic across runs.

Before that I spent five months at OVHai LLM in Paris rebuilding a training-data pipeline as a message-driven microservice architecture in Python, and published edgedb-dto to PyPI, a code generation library that turns EdgeQL queries into typed, validated Python models. Earlier still, as one of three fellows at Fellowship.ai, I co-authored a published method for scoring student piano performances that reached a 95.8% F-score against a 92.9% published baseline.

I implement protocols from scratch to understand them, which is where the BitTorrent and Kerberos projects below come from. Outside the terminal: Arabic, French and English, learning German, a pianist and a tennis player.

Experience

Data Platform Engineer

A US fashion house (via a consultancy) Jan 2025 to present

Nine fragmented source systems resolved into one trustworthy customer view on GCP.

7M+ customer records resolved to golden IDs · 83 SQLX models, ~6,600 lines of GoogleSQL · 694 of 841 commits mine · 124 Terraform files across 3 environments

The problem

A US fashion house, where I work through a consultancy, sells through e-commerce, physical retail and a set of marketing and planning tools that grew up separately. Nine heterogeneous source systems spanning commerce, ERP, marketing automation and planning each held part of the truth about the same customer, and none of them agreed. The same person existed several times over, spelled differently, with different addresses, in systems that had no shared key. Every question worth asking about a customer required a human to reconcile that by hand first.

I own two production repositories on Google Cloud that together answer it. I designed and built the BigQuery and Dataform ELT codebase from scratch, authoring roughly 83% of its 841 commits, and I maintain and extend the Terraform codebase that provisions the platform underneath it.

The constraint that made it hard

Identity resolution across those systems is a graph problem. If record A shares a phone number with record B, and B shares an email with C, then A, B and C are one person, even though A and C have nothing in common. The natural expression of that is connected components over a graph, and the platform is BigQuery, which is set-based SQL with no graph traversal and no procedural loop I would want in a nightly pipeline.

Three customer records resolved to one golden IDRecord 1 and record 2 are joined by a shared name and phone number. Record 2 and record 3 are joined by a shared name and email address. Records 1 and 3 share nothing directly. Connected components collapses all three into a single golden ID.one person, three rows, no shared keyrecord 1record 3record 2nothing in commonname + phonename + emailunion-findone golden ID
Three rows, two shared attributes, one golden ID.

The second constraint was determinism. The output feeds reverse ETL into systems that write back to customers, so the same input has to produce the same golden IDs on every run. A result that reshuffles between runs is worse than no result.

What I tried

The resolution engine implements Union-Find connected components entirely in SQL, using iterative CTEs. Candidate pairs are blocked on name plus phone and name plus email edges, which keeps the comparison space tractable, and the iteration collapses each component to a stable representative. That resolves over 7 million fragmented customer records into golden master IDs, and it does it declaratively, inside the warehouse, with no external orchestration.

Around it sits a layered medallion lakehouse: 83 SQLX models and roughly 6,600 lines of GoogleSQL. Raw data lands in incremental datalake tables keyed on a farm_fingerprint hash and partitioned hourly on ingestion timestamp, then flows through a cleaned staging layer, a work layer, conformed aggregates, and finally a Kimball-style star schema of dimensions, facts and business-facing views.

To keep that from becoming 6,600 lines of copy-paste, I wrote a 540-line JavaScript macro library that generates SQL at compile time: a declarative clean_string cleansing DSL (Unicode NFD normalisation, regex accept and reject rules, multi-format timezone-aware timestamp parsing, safe casting, null handling), an RFC 5322 email validator, a fuzzy address standardiser reconciling country, state, city and postcode against reference data, and a fingerprint-based slowly-changing-dimension merge generator.

The decision, and why

Idempotency had to be designed in. I traced a recurring delta drift to non-deterministic window functions: a ROW_NUMBER over rows with tied ordering keys picks an arbitrary winner, and arbitrary is not stable across runs. The fix was to enforce an explicit tiebreaker in every QUALIFY and ROW_NUMBER deduplication in the codebase, which removed the drift at the source instead of reconciling it downstream.

22 models carry declarative Dataform assertion suites for uniqueness and non-null, and over 100 DAG tags allow selective, tag-scoped execution so a change to one domain does not require a full rebuild. A CDC-style work, incremental and delta pattern feeds reverse ETL, where scripted BigQuery procedures chunk and export segmented customer and sales extracts to cloud storage for marketing activation and write-back across four regional segments.

The Terraform side is the same platform seen from below: roughly 125 files and 5,000 lines provisioning eleven BigQuery dataset modules, 25 JSON table schemas, nine batch ingestion flows, Pub/Sub topics, service accounts and 21 least-privilege IAM bindings, composed from the group’s versioned internal module registry with a pinned provider and remote state. Changes ship through dev, pre-production and production via approval-gated Azure DevOps pipelines with per-environment state backends. My work there has centred on schema evolution for new source fields, onboarding new external sources and export buckets, IAM remediation across environments, and clearing Terraform state drift with moved blocks and lifecycle rules.

The outcome

The platform runs in production. Silos across e-commerce, retail point of sale and marketing collapse into stable golden IDs, and the star schema on top is what the business actually queries. Observability is a Cloud Logging sink through Pub/Sub and Dataflow streaming errors to Datadog with a dead-letter topic, and delivery runs on Cloud Build triggers driving Cloud Workflows that compile and release Dataform versions through its API with retry and exponential backoff.

What I would do differently

The blocking strategy is the honest limitation. Name plus phone and name plus email are cheap and precise, and they miss the customer who changed both, which means recall is bounded by how much contact information happens to overlap. Adding a fuzzy blocking pass on standardised address, which the macro library already produces, would widen it without a full pairwise comparison.

I would also invest earlier in testing the macro library itself. The assertions cover the models it generates, which catches a bad result, but not the generator, which is where a subtle change silently rewrites 83 models at once.

Data Engineer Intern

OVHai LLM, Paris Feb 2024 to Jul 2024

Automated the corpus preparation behind neural machine translation models serving regulated finance.

4 containerised microservices built · 6 library modules refactored · batch-level error isolation over corpora of millions of sentences · thesis defended at INSAT, 25 September 2024

The problem

OVHai LLM trains Transformer-based neural machine translation models for banks, investment funds and other regulated financial institutions. Those models are only as good as the corpora behind them, and the data team was preparing every corpus by hand for each new language pair: slow, inconsistent, and under a garbage-in-garbage-out constraint, a direct risk to model quality. My five-month end-of-studies internship in Paris was to re-architect the internal platform that does that preparation, Datomatique, and automate the stages that were still manual.

The constraint that made it hard

The clients are the constraint. Banking data cannot leave the premises, which rules out managed cloud services and makes every infrastructure choice a self-hosting choice. And the workflow itself is an ordered pipeline carrying substantial payloads (documents, corpora, model outputs) rather than a stream of small notifications, which quietly rules out a lot of the standard event-driven advice.

What I tried

I ran a formal architecture trade-off analysis: monolith against microservices, then event-driven against message-driven. Microservices won on independent deployability, since the stages have genuinely different resource profiles, one of them needing GPUs. Message-driven won over event-driven on the specifics of the workflow: point-to-point semantics, guaranteed delivery and sequential workflow control fit an ordered pipeline, where publish and subscribe fits fire-and-forget notification, which this is not.

I benchmarked and justified each component the same way. ActiveMQ over RabbitMQ for its JMS foundation, multi-protocol support (AMQP, MQTT, STOMP), master and slave high availability and dual file- and database-backed persistence. Self-hosted MinIO over AWS S3 for cost, control and the on-premises data sovereignty the clients require. EdgeDB over PostgreSQL for graph-relational modelling and the expressiveness of EdgeQL. Poetry for dependency management and reproducible packaging.

The decision, and why

Before building anything new I refactored the core library into clean modules (Pipeline, Tasks, Messaging, Callbacks, Utils, Config) and fixed its three structural weaknesses, because new services built on a shaky library would have inherited all three.

Callbacks were hard-coded, so extending behaviour meant editing library source. I replaced them with a pluggable abstract callback system designed against the Dependency Inversion and Open/Closed principles, so users extend without touching the library. Logging was fixed, so I made it configurable across six severity levels with per-level sinks. And error handling was all-or-nothing: one malformed record stopped the world and the whole pipeline restarted. I introduced granular batch-level error isolation, capturing the failing record with its traceback and routing it aside so the rest of the batch continues. On a corpus of millions of sentences that is the difference between a pipeline that finishes and one that never does.

On that foundation I built four containerised microservices from a standardised boilerplate template repository: document extraction (sentence- or paragraph-level parsing of PDFs from object storage with automatic language detection), translation serving the lab’s in-house engines through Sockeye, COMET-based translation quality estimation, and monolingual and bilingual regex filtering driven by rules stored in EdgeDB.

Services communicate through a self-describing JSON envelope carrying the ordered task list, a current-task index each service increments, per-service configuration, the target queue, and dedicated exception and traceback fields. A message therefore knows its own remaining itinerary, which means no service needs to know what comes after it and the pipeline can be reordered by changing the message rather than the code.

A self-describing message envelope at two pipeline hopsThe same envelope is shown twice. Both copies carry an identical ordered task list of extract, translate, score and filter, plus per-service configuration, a target queue and exception and traceback fields. At the first hop the current index points at extract; after the document extraction service increments it, the index points at translate. Only the index changes.identical either side, except one linetasks: extract, translate,score, filterper-service configtarget queueexception / tracebackindex -> extractActiveMQtasks: extract, translate,score, filterper-service configtarget queueexception / tracebackindex -> translatedocument extractiontranslationincrements the indexreads its own next task
Only the index moves, so a message carries its own remaining itinerary and no service needs to know what follows it.

The outcome

The platform ships through self-hosted GitLab CI/CD, chosen for client confidentiality, with pre-commit hooks, automated documentation builds, Python wheel packaging, Docker image builds and registry pushes. I also shipped a MinIO CLI inside the library so the data team could manage buckets and artefacts without learning the underlying SDK, which was the smallest piece of work with the most immediate uptake.

The internship also produced an open-source spin-off: edgedb-dto, published to PyPI and now past 1,000 downloads. I delivered throughout under Kanban with an explicit definition of done gated on merge-request review, and defended the thesis at INSAT on 25 September 2024.

What I would do differently

The self-describing envelope is elegant. It is also unversioned. Every service parses the same JSON shape, so changing that shape means coordinating a deployment across all four at once, which is exactly the coupling microservices were supposed to remove. A schema version field and tolerant readers would have cost an afternoon.

I would also have pushed harder for integration tests across the message path. Each service was tested in isolation, and the failures that actually bite in a message-driven system are the ones between services: a queue that fills, a consumer that acknowledges before it finishes, a payload that is valid to the sender and unreadable to the receiver.

Machine Learning Engineer

Fellowship.ai (Launchpad.ai) Apr 2022 to Jul 2022

A published method for scoring student piano performances, at 95.8% F-score against a 92.9% baseline.

95.8% note-classification F-score against Benetos et al. at 92.9% and Wang et al. at 98.0% · HMM alignment 99.2% to 99.8% · three-person team, article published 4 October 2022

The outcome

On the evaluation dataset the method reached a 95.8% note-classification F-score, outperforming Benetos et al. at 92.9% and approaching Wang et al. at 98.0%. The comparison matters more than the ranking: both baselines rely on non-negative matrix factorisation requiring per-song dictionary training, with Wang et al. costing roughly one minute of compute per minute of audio. Our pretrained neural approach runs far faster and stays model-agnostic, so it improves automatically as transcription research advances, with no rebuild.

Three fellows worked jointly across research and development, so the honest description of my part is co-authorship of the published method plus hands-on work across the pipeline, including the alignment interoperability layer.

The problem

The brief was one word: “moderate music”. As one of three machine learning fellows at Fellowship.ai I worked on turning that into something a student could use, and we landed on performance assessment: record a student playing a piece, compare it against the score, and return quantitative feedback on what they actually played. We published the method, and I am a credited co-author.

We began with a literature and tooling survey across automatic music transcription, symbolic music processing and audio signal analysis, working in Python with librosa, music21, pretty_midi and optical music recognition for turning sheet music into MIDI.

The constraint that made it hard

Our first deliverable was a tempo-analysis algorithm for classical and student piano, where off-the-shelf beat trackers do poorly. From a mel spectrogram we extracted the onset strength envelope, derived a predominant local pulse curve, picked beats at pulse peaks, and computed BPM from inter-beat intervals, correcting the well-known metrical ambiguity where estimates land at two or three times the true tempo by reconciling candidates against a reference BPM.

Then we ran it on a virtuoso recording and on a score-synthesised rendition of the same piece, and the result reframed the entire project. Expressive rubato makes a human performance diverge constantly from the synthetic reference. Naive audio comparison cannot separate artistry from error, because at the signal level a beautiful interpretive stretch and a mistake look identical. Any system built on that comparison would penalise musicianship.

What I tried

We redefined the task: evaluate the performance against the composer’s symbolic intent, not against a reference audio rendering, leaving room for expression by design. That pushed us into automatic music transcription, where we hit a reproducibility wall. Several high-profile papers shipped without source code, pretrained weights or usable environment documentation, and reimplementing them was not a three-month proposition.

We settled on Kwon et al.’s autoregressive multi-state note model (ISMIR 2020), pretrained on MAESTRO and capable of real-time operation.

The decision, and why

The model’s onset F-score was 94.7%, but onset plus offset detection reached only 79.4%. Rather than accept 79.4% across the board, we discarded offset detection entirely and quantised every transcribed note to a fixed crotchet: we gave up duration information we could not trust in exchange for note identification we could. For assessing which notes a student played, that trade is straightforwardly worth making.

Alignment was harder than expected. Dynamic time warping over note-onset series broke down on chords and polyphony, where several notes share an onset and the warping path has no way to choose. We adopted Nakamura et al.’s hidden Markov model symbolic alignment (ISMIR 2017), which reaches 99.2% to 99.8% accuracy at very low computational cost. It ships as a C++ binary emitting loosely structured output, so I helped build the Python interoperability layer that parses its results into pandas DataFrames and integrates it into our pipeline, producing a per-note classification of reference, incorrect, missing or extra.

We exposed the pipeline as a Flask REST API taking a raw audio recording plus a reference MIDI file and returning the labelled note list, then built a cross-platform React Native client handling recording, API interaction and rendering of annotated sheet music.

What I would do differently

Discarding offsets was the right call in a three-month window and it remains the limitation of the result: the system tells a student which notes were wrong and nothing about how long they held them, so it cannot see rushing, dragging or a pedal held through a rest. Transcription offset accuracy has improved since, and the pipeline is model-agnostic, so fixing it is a swap.

The reproducibility wall deserved to be written up. We lost real time to papers that could not be run, and publishing our own environment and evaluation harness alongside the article would have been the useful response.

Projects

edgedb-dto

Code generation that gives EdgeDB object composition back, without becoming an ORM.

MIT on PyPI, past 1,000 downloads · 72 of 75 attributed commits mine · ~651 lines across 6 modules · 2 releases, 2 worked examples, flake8 with 7 plugins across 3 pre-commit stages

The problem

EdgeDB ships an official code generator that turns each .edgeql file into a standalone typed Python function. It is fast and correct. It is also flat. There is no way to compose queries across related object types, no validation over the inputs, and every project that uses it ends up writing the same glue between query functions and application code by hand. That glue is precisely the ergonomics an ORM would have provided. I wanted the composability back without dragging in an ORM, because the reason to use EdgeDB in the first place is that EdgeQL is better at expressing the query than an ORM’s fluent API is.

The constraint that made it hard

A generated query function knows nothing about the objects around it. Given insert Course and insert Student, the generator emits two functions whose only relationship is that one of them takes a UUID the other happens to return. Composition means letting a field hold either a resolved identifier or another unresolved query, and then working out, at runtime, what order to execute them in. The library also had to stay out of the query’s way: the moment it started rewriting EdgeQL it would have become the query builder I was trying not to write.

What I tried

I built the generator as a two-stage metaprogramming pipeline, because neither stage alone is enough. Statically, I parse each generated module with Python’s ast module to pull out its import graph and class definitions without executing anything, then rewrite that graph: excluding modules that must not be re-exported, injecting the library’s own runtime symbols, remapping the standard dataclasses decorator onto Pydantic’s validating equivalent, and grouping and alphabetising the result so output stays deterministic and diff friendly. Dynamically, I load each query function through importlib’s spec-from-file-location machinery and introspect it with inspect.signature to recover parameter names, annotations and return type, which the AST alone cannot resolve.

The decision, and why

The whole design turns on one rewrite: every uuid.UUID field becomes DTO | uuid.UUID. That is one line of declarative configuration, and the entire reason composition works. A generated field can now hold either a resolved identifier or another unresolved DTO, which pushes the problem into the runtime where it belongs.

The runtime half is a base class implementing execution semantics. Running a DTO walks its own annotations and resolves each attribute recursively, depth first, so any attribute that is itself a DTO has its query executed first and is substituted by the returned identifier. The resolver handles nested lists, plain tuples and namedtuples, rebuilding namedtuples from their _fields metadata, and the whole traversal optionally runs inside a transaction so an entire object graph commits atomically. Everything else follows from configuration rather than control flow: defaulted fields reordered after required ones as the dataclass protocol demands, default_factory attached to collection types matched by regex, and one Jinja2 template emitting synchronous or asynchronous variants from a single code path, selected by detecting an _async filename suffix.

The outcome

edgedb-dto is on PyPI under MIT, past 1,000 downloads, at 96% of attributed commits (72 of 75) across roughly 651 lines in 6 modules. The shipped example builds a student, an instructor and two shared courses, then persists the full graph with a single call.

The release pipeline is the part I would point at second. A SemVer tag pattern gates a GitLab CI job that builds with Poetry and dual publishes to both PyPI and the GitLab package registry, then cuts a release, with credentials injected from masked variables. Quality is enforced by a pre-commit suite spanning the commit, push and commit-message stages: black, refurb, flake8 with seven plugins including pydocstyle and bugbear, codespell, rstcheck, and a dozen hygiene hooks.

What I would do differently

Two things, both documented in the repository.

  • The resolver has an N+1 pattern, a direct consequence of deliberately not being a query builder: each nested DTO executes its own query rather than being folded into one statement. The sharper version of the same fault shows up in the linked_dtos example, where the same two course DTOs appear in both the instructor’s and the student’s courses list. The resolver substitutes the returned identifier into the parent’s field, not into the shared child, so a DTO referenced from two parents executes twice. That is harmless against the idempotent unless conflict upserts the examples use, but it is the same root cause, and a resolved-node cache keyed on object identity would fix both.
  • There is no test suite. The pre-commit config already excludes a tests/ directory that never landed. For a library other people install, that is the most valuable thing I could add to it.

seedify

BitTorrent from the bytes up, including the BEP 15 UDP announce packed by hand.

~1,841 lines across 22 modules · 30 commits, Feb to Jun 2023, two engineers pair-programmed · rarest-first selection, endgame at 20 outstanding pieces, top-four unchoke every 30s

What it does

It joins a real swarm, talks to trackers over both HTTP and UDP, downloads a torrent piece by piece, SHA-1 verifies every piece against the metadata, and writes the result across file boundaries at the right offsets. Roughly 1,841 lines of TypeScript across 22 modules, written with a partner over screen share between February and June 2023.

The problem

BitTorrent is the rare protocol that is fully specified, still widely deployed, and small enough that two people can implement it end to end. We wanted to know how a swarm actually works: not the summary, but the byte layouts, the state machines and the incentive mechanism.

Choosing TypeScript was deliberate. Existing clients are written in Python and C++, and we gave up that ecosystem precedent on purpose: over a notoriously fiddly binary protocol, static typing and readable asynchronous code were worth more to us than a library that already did the hard part.

The constraint that made it hard

Trackers speak two incompatible transports. HTTP announce is a percent-escaped query returning a bencoded body, which is tedious but forgiving. UDP announce (BEP 15) is neither. It is a connect handshake using the protocol’s magic constant, four-byte transaction IDs echoed back and verified against every response, and a 98-byte announce payload with 64-bit downloaded, left and uploaded counters plus a numeric event code written at fixed byte offsets. Nothing in that path fails loudly. Get an offset wrong and the tracker simply returns something that is not an error and not the peers you asked for.

Byte layout of the 98-byte BEP 15 UDP announce requestThe announce payload is 98 bytes written at fixed offsets: connection_id 8 bytes at offset 0, action 4, transaction_id 4, info_hash 20, peer_id 20, then the 8-byte downloaded, left and uploaded counters, then the 4-byte event, ip, key and num_want fields, and finally a 2-byte port at offset 96. The box labelled txn_id is transaction_id, and the box labelled want is num_want.98 bytes, fixed offsets, packed by handconnection_idactiontxn_id081216info_hashpeer_id163656downloadedleftuploaded56647280eventipkeywantport, 2 bytes808488929698
To scale at 13 units per byte. Get one offset wrong and the tracker answers, just not with peers.

What I tried

We put both transports behind one interface and let the announce state machine stay ignorant of which one it was talking to. That state machine (stopped, connecting, waiting, error) drives the whole announce lifecycle: scheduling re-announces from the interval the tracker returns, retrying with a thirty second backoff on transient failure, and shutting down cleanly when DNS fails unrecoverably.

Two tracker transports behind one interfaceThe announce state machine, with states stopped, connecting, waiting and error, sits above a single tracker interface. Two handlers implement that interface: an HTTP handler building a percent-escaped query and decoding a bencoded reply, and a UDP handler implementing BEP 15 with a hand-packed 98-byte payload. The state machine never learns which one it is talking to.the state machine never learns which one it hasannounce state machinestopped · connecting · waiting · errorone tracker interfaceHTTP handlerUDP handler, BEP 15percent-escaped, bencoded98 bytes at fixed offsets
Adding the UDP transport meant one class against an existing contract, and no change above the interface.

Everything before that is metadata handling. We bencode-decode the .torrent dictionary, re-encode the info sub-dictionary and SHA-1 hash it to derive the infohash that identifies the swarm, flatten multi-tier announce-list entries, and normalise single-file and multi-file torrents into one shape carrying per-file lengths and byte offsets, so nothing downstream has to care which kind it is. Returned peers are decoded from both the compact form (six raw bytes of IPv4 address plus a big-endian port) and the legacy dictionary form.

The decision, and why

The interesting decisions are in scheduling. Piece selection is rarest first, driven by per-piece availability counters maintained incrementally as peers send bitfields, announce have, and disconnect. When all but twenty pieces are outstanding the client flips into endgame mode and issues randomised duplicate requests to every peer holding them, which is wasteful by design: it stops one slow peer stalling the tail of the download.

On the upload side we implemented tit-for-tat choking, which is the protocol’s incentive mechanism. Every thirty seconds the client ranks peers by transfer rate over a sliding history window, unchokes the four fastest interested ones, and adds a random optimistic unchoke so a newcomer with nothing to trade can still bootstrap.

Between the two sits the peer wire protocol: handshake construction and validation, reassembly of the raw TCP stream into length-prefixed frames through a buffered incremental parser, and the full message set (choke, unchoke, interested, not-interested, have, bitfield, request, piece, cancel, port and keep-alive). Every completed piece is SHA-1 verified against the metadata hash, with the block bitset reset and the piece re-requested on mismatch, and pieces spanning file boundaries are written across multiple descriptors at computed offsets.

The outcome

It runs with a cap of thirty peer connections, sixty second keep-alives and disconnection after a hundred seconds of inactivity. The clearest payoff from working to the Open/Closed principle was the second tracker transport: adding UDP meant writing one class that satisfied an existing interface, with no change to the announce state machine at all.

What I would do differently

The README oversells it, which is the first thing I would fix. Bandwidth throttling is written but commented out. handleLeecher in the seeder is stubbed, so inbound connections are accepted but seeding never completes: the client is an honest leecher and a dishonest seeder. Both are small pieces of work that were left at the point where the interesting problems had run out.

There is also no test suite, on a codebase that is mostly byte layouts and state transitions, which is exactly the kind of code where a test suite pays for itself. A property test over bencode round-tripping and a fixture-driven test of the 98-byte announce payload would have caught more than the manual swarm testing did.

Nesty-kerberos

The Kerberos three-legged ticket flow implemented properly, nested encryption included.

~1,433 lines across 2 NestJS applications · 9 of 10 commits mine · 2 realms, 256-bit session keys, 120s authenticator skew, 2h to 10h lifetime clamp, MODP group 15

The problem

Almost every write-up of Kerberos stops at the diagram: client, Key Distribution Centre, service, three legs, some arrows. The diagram is not the difficult part. The difficult part is that the protocol proves a client holds a key without that key ever crossing the network, and it does that through encryption nested inside encryption. I wanted to implement it rather than read about it, so I built it across two independently deployable NestJS services, roughly 1,433 lines of TypeScript: a KDC exposing the Authentication Service and the Ticket Granting Service, and an application service that validates service tickets and manages users, with Redis and PostgreSQL behind them under Docker Compose.

The constraint that made it hard

Nested encryption is a cross-cutting concern that touches every endpoint, and an asymmetric one. On the way in, the server has to decrypt a ticket-granting ticket under the TGS’s own long-term key, pull the session key out of the decrypted ticket, and only then use that session key to decrypt the client’s authenticator. On the way out it has to encrypt two different payloads under two different keys for two different readers. Writing that inline in each handler would have meant repeating the trickiest code in the system in every place it was needed, and getting one ordering wrong silently produces a system that appears to work while proving nothing.

What I tried

I put the whole scheme in a NestJS interceptor wrapping every KDC endpoint, so the handlers see decrypted, validated input and return plain objects. Inbound, the interceptor decrypts the ticket-granting ticket with the TGS long-term key, recovers the session key from inside that ticket, and decrypts the authenticator with it. That is the step that carries the protocol’s actual guarantee: the client demonstrates possession of a key it never transmitted.

Nested decryption of a ticket-granting ticketThe client sends a ticket-granting ticket and an authenticator. The interceptor decrypts the ticket with the Ticket Granting Service's long-term key, which yields a session key from inside the ticket. That session key then decrypts the authenticator. The session key never crosses the network, so opening the authenticator proves the client holds it.one interceptor, every KDC endpointTGT, sealedauthenticator, sealedTGS keyticket contentssession keynever sentopensthe client holds a key it never transmitted
The ticket opens under the TGS key, the session key comes out of the ticket, and that key opens the authenticator.

Outbound, it mints a fresh 256-bit session key from a CSPRNG, assembles a ticket carrying the principal, username, source IP, timestamp and negotiated lifetime, and double-encrypts the response. The ticket goes under the target service’s key so only that service can open it. The challenge goes under the client’s key. Neither party can read the other’s half.

Registration happens before any of that, and it has the same requirement: no credential in plaintext on the wire. The client and server run an ephemeral Diffie-Hellman exchange over MODP group 15, derive a shared secret that lives in Redis for 120 seconds, and the client encrypts its password under that secret for the server to decrypt server side before persisting the user through TypeORM with a role attached.

The decision, and why

Encryption on its own does not imply the protocol’s security properties, so I enforce each of them explicitly. The TGS rejects a ticket presented from an IP other than the one it was issued to, rejects a mismatch between the username inside the ticket and the one in the authenticator, rejects expired tickets, and rejects authenticators whose timestamp skew from the ticket exceeds two minutes. The Authentication Service looks up client and service principal keys from a Redis-backed keytab and clamps the negotiated ticket lifetime between per-realm bounds of two to ten hours.

Replay protection is the piece I am happiest with, because the data store does the work. A Redis entry keyed on principal, username and realm has its TTL set to the ticket’s remaining lifetime, so a captured authenticator cannot be reused inside exactly the window in which it would still be valid, and the entry expires itself the moment it stops mattering. The application service’s ticket manager mirrors the same validation for the final service-ticket leg.

Multi-tenancy runs all the way down: every principal, session key, replay entry and lifetime policy is namespaced by realm, and the configuration ships two realms to demonstrate the isolation.

The outcome

The full three-legged flow works end to end across both services: register, authenticate, obtain a ticket-granting ticket, exchange it for a service ticket, present that ticket to an application service that validates it independently. Underneath sits a crypto service wrapping AES-256-CBC with a random per-message initialisation vector, SHA-256 hashing and secure key generation.

Known limitations

This is a 2023 learning build, written to understand the protocol, and it has real faults. I would rather name them than have someone find them.

  • Passwords are stored raw. UserService.create decrypts the password and writes it into both Redis and Postgres. Real Kerberos derives a long-term key through a string2key function and never keeps the password at all. I would derive with Argon2 or PBKDF2, then discard.
  • AES-256-CBC is unauthenticated, so ciphertexts are malleable and an attacker can flip bits undetected. AES-256-GCM, or encrypt-then-MAC with HMAC-SHA256, is the fix.
  • The interceptor leaks plaintext. It returns dec_1 and dec_2, the decrypted ticket and challenge, alongside the encrypted response. Debug output as written, and it hands the client plaintext it should never see, which defeats the encryption entirely. Delete before this is used for anything.
  • Secrets are committed, including .env files and a hard-coded service key in realms.ts.
  • The test suite is scaffolded and empty. For a security protocol that is the sharpest gap of all: the tests are the argument that the implementation is correct.

yummy

A till that keeps taking orders when the internet drops.

~2,050 lines, 30 of 30 commits mine · 5 entities, history paginated 10 records at a time · Dec 2022 to Apr 2023 · context isolation on, Node integration off

The problem

A restaurant till has one requirement that outranks every feature on the list: it has to keep taking orders when the connection goes. Most point-of-sale software treats a server as the source of truth and degrades into an expensive paperweight the moment that assumption fails. Yummy inverts it. The application owns its own database, and the network is something it uses when available rather than something it depends on. I designed and built it solo over five months on Electron and Angular 13, roughly 2,050 lines of TypeScript plus templates, and all thirty commits are mine.

The constraint that made it hard

Owning your data locally in Electron pulls in a security problem immediately. The obvious way to give an Angular renderer a database is to switch on Node integration, at which point any script that reaches the renderer has the filesystem. The safe configuration (context isolation on, Node integration off) is the right one, but it means the renderer cannot touch persistence at all, and every repository operation has to cross a process boundary as a message.

What I tried

I embedded SQLite in the Electron main process through TypeORM, initialising the data source against a file under the OS application-data directory and registering a repository per entity in a lookup map. A preload script installs a context bridge, and an Angular store service wraps that bridge so components call ordinary typed methods while the main process performs the actual repository work. The renderer never touches Node, and the UI code does not read as though anything unusual is happening.

The domain is five entities (users, categories, products, purchases and bills) over a base entity carrying an auto-increment key, a creation timestamp, a soft-delete column and a synchronisation flag. Cascading relations link categories to products and bills to their purchase lines. Deletes are soft throughout, which is a data-modelling decision: a bill from March references a product that was removed in April, and the receipt still has to render correctly a year later.

The decision, and why

The authentication design is where offline-first stopped being a slogan. Credentials are verified locally against bcrypt hashes with per-user salts, so a server signing in to start a shift has no network dependency at all. Administrators additionally validate against a remote API, which reintroduces exactly the failure I was trying to avoid, so I cache the returned JWT locally and short-circuit the remote round trip whenever a cached token is still unexpired, checking expiry client side. Admin login degrades instead of failing.

The billing workflow keeps the active bill in a service that publishes state through an RxJS replay subject, so components stay synchronised without prop drilling: adding a product either creates a purchase line or increments the quantity of an existing one, removing decrements and deletes at zero, and closing a bill persists it with the current user attached and rolls a fresh one. History is browsable through a lazily paginated cursor fetching ten records at a time, newest first, scoped to the logged-in user, eager loading each bill’s purchases and their products and including soft-deleted rows so old receipts still render.

Route guards and resolvers gate access and prefetch data across lazily loaded feature modules for authentication and the main workspace, with a shared module for the reusable card and loader components. It ships as a cross-platform binary through electron-builder using the two package file layout that keeps runtime dependencies out of the bundle.

The outcome

The core loop works and it works offline: sign in, build a bill, close it, browse history, all against local SQLite with no server in the path. The Electron security posture is right where it counts, with context isolation enabled and Node integration disabled.

Known limitations

This is a 2023 learning build. The README claims more than the code delivers, and I would either build the rest or cut the list.

  • The preload bridge is the thing I would change first. It exposes generic send, invoke and on for any channel name, which reopens the attack surface context isolation exists to close. The fix is a named channel allowlist, a small change: the hard part, keeping Node out of the renderer, is already done.
  • admin.guard.ts returns true unconditionally, which means every route it is supposed to protect is open.
  • Printing and stats are empty classes. The stats IPC handler builds a TypeORM query builder and never calls .getRawMany(), so it returns the builder and never the rows.
  • Synchronisation is modelled, not built. The synced flag and the remote API exist, with no reconciliation engine behind them.
  • copyFile writes into the application path, which is read-only inside a packaged asar bundle, so it works in development and breaks once shipped; it should target the user-data directory.
  • synchronize: true on TypeORM is fine while developing and will silently alter schemas in production, where real migrations are the answer.

What I would do differently

Every entry on this page ends with its own faults. This is all of them in one place.

EntryYearWhat I would do differently
Data Platform Engineer, A US fashion house (via a consultancy)2025Blocking on name plus phone and name plus email is precise and bounded, so a customer who changed both is missed. A fuzzy pass on standardised address would widen recall.
Data Engineer Intern, OVHai LLM, Paris2024The self-describing message envelope is unversioned, so changing its shape means deploying all four services at once. A version field and tolerant readers would have cost an afternoon.
Machine Learning Engineer, Fellowship.ai (Launchpad.ai)2022Discarding note offsets bought trustworthy note identification and cost every duration signal, so the system cannot see rushing, dragging, or a pedal held through a rest.
edgedb-dto2024The resolver runs one query per nested DTO, and a child shared by two parents runs twice. A resolved-node cache keyed on object identity fixes both. There is no test suite.
seedify2023An honest leecher and a dishonest seeder: handleLeecher is stubbed and bandwidth throttling is commented out. No tests, on a codebase that is mostly byte layouts and state transitions.
Nesty-kerberos2023A 2023 learning build with real faults: passwords stored raw instead of derived through string2key, unauthenticated AES-256-CBC, a debug leak handing the client plaintext, and committed secrets.
yummy2023admin.guard.ts returns true unconditionally, printing and stats are empty classes, synchronisation is modelled but not built, and the preload bridge exposes generic channels instead of an allowlist.

Get in touch