full-stack · DevOps · Seoul, Korea
Open to senior IC roles

Jon Karlsen

Software that earns its keep.

Code that holds up to a second reading.

I led the cloud migration at Azets Document Solutions from 2022 to 2025. The architecture was the headline; the work that actually landed the migration was organisational — how a team ships, and what you build so the shipping survives first contact with real users. I came out of it wanting more of both.

ADS was winding down operations and I was moving to Seoul. I used the gap to find out whether I could carry something of my own end-to-end — product, infra, positioning, pricing. The projects below are that attempt so far. Building in the open is the constraint I chose: it keeps me honest about what’s actually working.

No. 01 — Case study

Haechi

Korean grammar review that works just as well offline as online.

Haechi is a spaced-repetition system I built for myself to learn Korean grammar. You browse a structured grammar reference — about forty-five patterns, mostly intermediate to advanced (-도록, -기는 하다, -ㄴ 적이 있다, -다시피, that sort of thing) — hit Study on a pattern, and the app generates a deck of typed flip-cards from the pattern’s definition and examples. You review daily, rate each card Again / Hard / Good / Easy, and the scheduler picks what you see tomorrow. There’s a Reader surface alongside it: paste any Korean text, tap a word to see its dictionary form and English gloss, save it as a vocab flashcard. Live at haechi.jon-karlsen.dev since April 2026.

The piece I’d put in front of a reviewer first is the scheduling model. Haechi implements FSRS-5 (the modern spaced-repetition algorithm) via the ts-fsrs library, and the scheduler runs in both places: on the client for offline reviews against an IndexedDB cache, and on the server for authoritative persistence. Both sides import a single shared module — one library version, one function. The user-tunable parameter the algorithm cares about flows over the wire on every review, so client and server schedule the same card the same way every time, by protocol rather than by hope.

I started building the app because my Anki decks for Korean grammar kept rotting — rules lived in my head, not in the cards, and the card-edit loop was too painful to keep them fresh. Haechi puts the grammar in version-controlled MDX and generates the cards from it. I’ve used it daily since it shipped. The architecture section below is about the scheduler’s client/server shape and what happens when a user does fifty reviews offline and then comes back online.

Architecture

Spaced repetition is a deceptively small problem. Every card you’ve ever made has two numbers attached — stability (how long until you forget it) and difficulty (how hard it is for you personally) — and every time you rate a card, an algorithm takes those numbers plus your rating and spits out a new due date. The algorithm is what distinguishes Anki from SuperMemo from Bunpro. FSRS-5 is the contemporary one, maintained as an open-source reference with language ports; in TypeScript, ts-fsrs is the canonical port. It’s on the order of thirty lines of pure stateless math. The question isn’t which algorithm. It’s where to run it.

The naive answer is on the server. Client fires a review, POSTs the rating, server schedules the next due date, database wins, UI rerenders. That’s how most SRS apps work — Anki syncs this way, Bunpro is server-first. It also means the app is useless on a subway. I wanted Haechi to work through a lunch-hour session with no signal, so the scheduler had to run on the client too. Two runtimes, one piece of math. The version-drift trap is well-known: the client ships with one ts-fsrs version and the server with another, and for weeks nobody notices that a card rated Good on the phone produces a different due date than the same card rated Good on the server. Pin it via a single dependency in package.json and a single shared module both sides import. Done — until you let the user touch a parameter.

FSRS-5 has a flag called enable_short_term: when on, Easy cards still go through brief retention checks before graduating; when off (the default in Haechi’s Settings → Study), Easy jumps the card straight to a long interval. Most users want it off. A few power users want it on. The parameter has to be honored on both runtimes per-review, not just per-deployment. So the shared module isn’t a constant — it’s a function:

// src/lib/fsrs-config.ts
import { generatorParameters, type FSRSParameters } from 'ts-fsrs';
import { DEFAULT_SHORT_TERM_ENABLED } from './study-config';

export interface FsrsOpts {
  shortTerm?: boolean;
}

export function getFsrsParams(opts: FsrsOpts = {}): FSRSParameters {
  const shortTerm = opts.shortTerm ?? DEFAULT_SHORT_TERM_ENABLED;
  return generatorParameters({ enable_short_term: shortTerm });
}

Both the client’s offline scheduler (src/lib/srs.ts) and the server’s review endpoint (src/pages/api/review.ts) import getFsrsParams and call it with the same argument shape. The argument itself moves over the wire: every review POST carries a shortTerm: boolean field. The client reads its setting, sends its preference, the server uses that exact value when it constructs its own scheduler instance for the same card. Same library version, same function, same parameter input — guaranteed by protocol, not by hope.

The comment in the source spells out what happens when older clients or out-of-band requests omit the field:

Client and server must produce IDENTICAL scheduling for a given card. The client sends its preference in the /api/review POST body; the server uses that exact value. If the field is absent (older clients, requests built elsewhere), the server falls back to DEFAULT_SHORT_TERM_ENABLED so behavior stays predictable.

That fallback isn’t graceful degradation — it’s a contract. If the server sees no preference, it assumes the canonical default; if a client wants something else, it has to say so explicitly. There is no implicit state on either side; the parameter is part of the request.

The question that falls out of two-runtime scheduling is what happens when the user does fifty reviews on the subway and then comes back online. Each card has an updatedAt timestamp and a full FSRS state — due, stability, difficulty, reps, lapses, state. The client has written new state to IndexedDB fifty times; the server still has the card from last week. On reconnect, the client POSTs its full card state to /api/sync along with a log of every review it did offline (each log carries state_before and state_after snapshots of the card). The server does two things: insert the review logs with onConflictDoNothing() by UUID (logs are append-only, deduplicated by id, never overwritten), and merge the cards with last-write-wins on updatedAt. If the client’s copy was updated more recently than the server’s, the server accepts the client’s FSRS state. Immutable review logs, mutable card state, timestamp-based reconciliation.

The question I get from engineering reviewers is why not CRDTs, or a real sync engine like Replicache or Zero? The product doesn’t need them. The only concurrent-write scenario Haechi has is a single user with a single device going offline and then coming back online, and in that scenario last-write-wins is correct by construction — the server hasn’t touched the card during the offline window, so the client’s newer updatedAt always wins. If the app ever ships multi-device sync, the scaffolding is already in place: every review log carries a full state_before and state_after snapshot, so the history a real reconciliation algorithm would need is already persisted. The reconciliation isn’t implemented yet; I don’t want to write it until I have users who need it. The schema is shaped so the implementation, when it comes, is additive rather than a rewrite.

What this pattern is worth, as a hiring signal, is the recognition that the universal-JavaScript era gave us a tool — share code across client and server — that most applications use for view rendering and almost none use for stateful domain logic. Scheduling is stateful domain logic. Doing it twice, once on the phone and once in Postgres, is the kind of duplication that creates drift bugs six months in, and the cheapest possible fix is to make the duplication structural: one library version, one function, one wire format that propagates the user’s choices, two runtimes that agree by construction. The scheduler ended up being the smallest file in the project. The sync layer that wraps it is substantially bigger. That ratio feels right.

Shipped, in flight, deferred

Live as of April 2026: grammar study with FSRS-5 scheduling against the forty-plus patterns in the launch reference, IndexedDB offline support with background sync, a Reader with tap-to-lookup and morpheme-level grammar overlay (learned patterns highlight inline in arbitrary Korean text, backed by the Kiwi WASM tokenizer), an AI writing tutor (Claude Haiku analyses a short writing prompt and returns blind-spot detection + grammar suggestions), vocab capture from the Reader into the flashcard deck, configurable per-session new-card cap, multi-user authentication via better-auth, and session resume in the Reader. Test coverage: a Playwright end-to-end suite and a Vitest unit suite, both green on CI.

In flight: production hardening of the Reader (POS allowlist on the lookup endpoint, rate limiting), and iteration on the writing tutor (grammar suggestions plus a usage heatmap). Deferred: community-contributed grammar patterns (the schema exists, the submission flow doesn’t), multi-device sync (the review-log snapshots will do the work once demand is real), and Korean-domestic payment processors — KakaoPay and Naver Pay — for a paid tier post-launch.

Astro 6 · Preact 10 · Drizzle ORM · Postgres on Railway · better-auth · ts-fsrs (FSRS-5) · Dexie.js (IndexedDB) · Kiwi NLP (Korean tokenization) · Anthropic SDK (Claude Haiku) · Tailwind CSS 4 · Playwright E2E · Vitest
No. 02 — Case study

Odin

A dashboard that tells you what to work on before you open GitHub.

Odin is a dashboard I built to answer one question: what should I work on before I open GitHub? It watches every repo a user owns, runs each one through a Claude-powered triage pass, and returns a ranked shortlist — failing CI here, stale PR there, a deploy that went red thirty minutes ago and needs a verdict — with a short briefing of two to four sentences that explains the ranking in plain English and calibrates itself against the developer’s own feedback on past triage items. I shipped it at v2.3.0.0 in April 2026.

The data-protection model is the piece I’d put in front of a reviewer first. Twenty-three user-scoped tables are gated by Drizzle pgPolicy() row-level security, and every transaction that serves a logged-in user runs through a withUserScope() wrapper that issues SET LOCAL app.current_user_id, reads the setting back, and aborts the transaction if it didn’t stick. RLS you can’t verify isn’t RLS; it’s a config file. The architecture section below walks through why that guard exists and what it costs.

Four days after shipping, I wrote a document to myself that started with “context switched blindly.” I had shipped Odin. I hadn’t used Odin. The web app is correct; it’s in the wrong place — my attention isn’t in a browser tab, it’s in a terminal. Odin is being rebuilt as a Go TUI. The case study below is the work that shipped; the closing note is where the thinking went next.

Architecture

Odin began as a single-user tool. The data model didn’t strictly need multi-tenancy — the deploy target was a Railway service I owned, and the authenticated user was always me. But single-user and personal aren’t the same thing; once you build something for yourself, you find yourself one good Tuesday away from wanting to share it. Multi-tenancy retrofits are where correctness bugs live, so I paid the tenancy tax up front.

The obvious approach in 2026 is Postgres row-level security. Every user-scoped table declares a set of policies — select, insert, update, delete — each gated by userId = current_setting('app.current_user_id'). The application sets that session GUC at the start of every transaction and lets the database itself enforce isolation. In Odin, twenty-three tables are policy-gated this way: repos, snapshots, events, triage results, triage feedback, deployments, deploy errors, Railway service links, Locomotive logs, confidence history, profiles, and the rest. The Drizzle schema declares each policy inline with the table definition, so the policy lives next to the column it protects and travels with migrations:

const currentUserId = sql`current_setting('app.current_user_id', true)`

// …inside a table definition:
pgPolicy('repos_select', {
  as: 'permissive',
  for: 'select',
  using: sql`${table.userId} = ${currentUserId}`,
}),

One shared currentUserId template, reused across every policy, keeps the predicate legible and the failure surface small. This is the textbook pattern. The part worth spending words on is what comes next.

The failure mode the guard is designed to catch is not exotic. Without verification, any single break in the chain — a driver quirk, a pooler that resets session state between statements, a transaction that retries without re-issuing the GUC — silently permits a query to run with no tenancy scoping. SET LOCAL returns success; the permissive default handles the rest; the policy check falls through against an empty setting; rows come back from the wrong tenant. You ship and find out in production, after the policy has served unscoped rows for an unknown duration. The permissive default is a trap.

The wrapper in server/db/index.ts closes it. Every user-scoped query goes through withUserScope(userId, fn), which first validates that userId matches ^[a-zA-Z0-9_\-]+$ (because SET LOCAL can’t be parameterized and so has to be composed as SQL — a separate issue worth its own guard), opens a transaction, issues SET LOCAL app.current_user_id = '…', and then runs one more query: SELECT current_setting('app.current_user_id', true). The returned value is compared to the userId the caller passed in, and if the two disagree the transaction throws before any real query runs. The comment in the source reads:

All user-scoped queries MUST go through this wrapper for defense-in-depth. The guard SELECT verifies SET LOCAL actually worked. If it didn’t (bad connection, pooler issue), the transaction is aborted with a 500 instead of silently running queries without RLS scoping.

The cost is one extra round-trip per request — about a millisecond at Railway’s Postgres latency, invisible at Odin’s scale, still cheap at anything short of thousands of QPS. If the application ever grows past that, the right move is to fold the set-and-verify into a single server-side Postgres function, not to remove the check.

The question I get from engineering reviewers is why not skip RLS entirely and put WHERE userId = ? on every query? Two reasons. First, auditability: with RLS, tenancy is enforced by a declaration next to the column, and a reader — future me, mostly — can see at the schema level exactly which tables are gated and how. Application-side predicates are a social convention; the next query someone writes has to remember them, and the ORM won’t warn when it doesn’t. Second, background jobs. Odin’s Railway poller, webhook handlers, and email digest runners all read user data without a user session, and the honest way to handle them is to reach into the service-link tables directly and bypass RLS in cron code paths, acknowledged in a comment: “Read service links directly (bypasses RLS — cron has no user session).” An app-side predicate system has to re-derive that decision in every query. The RLS system forces exactly one place to make it.

What falls out is clean: user code goes through withUserScope(), system code bypasses RLS with an acknowledging comment, and the connection pooler doesn’t get to decide which. Twenty-three tables, four policies each, one wrapper, one verified setting. It is less code than the exception handlers I would have written around app-side predicates, and it lives closer to the data.

Shipped, and what’s next

Shipped at v2.3.0.0 in April 2026 and now frozen: AI triage with a feedback learning loop, Railway deploy verdicts (Claude Sonnet for initial analysis, Haiku for streaming confidence updates as logs arrive), a thirty-minute post-deploy observation window that correlates runtime errors back to deployed commits, deploy verdicts posted to GitHub as commit statuses, weekly retro emails, daily briefings via Resend, real-time log streaming through a Locomotive sidecar over WebSockets. The web app is still live at odin.jon-karlsen.dev but no longer accepting features.

What’s replacing it is a Go terminal TUI on the pivot/ branch. v0.0.1 (core router, SQLite cache, Railway GraphQL client via genqlient) and v0.0.2 (deploy detail pane, log tail, exponential-backoff polling) have shipped; the project as a whole is at v3.x. The RLS wrapper, the deploy verdict chain, and the prompt structures are sound work; the wrong call was the form factor. Odin is the same product, moving to where my attention already is.

Nuxt 4 · Vue 3 · Drizzle ORM · Postgres on Railway · better-auth · Anthropic SDK (Sonnet + Haiku) · Railway GraphQL + webhooks · Nitro WebSocket · Resend · Stripe

Also building

If you’re reading this because you’re hiring, reach me at karlsen.jon@icloud.com. Résumé at /resume.pdf. GitHub and LinkedIn are easy to find. Older writing, training logs, and TAOCP notes live at /archive.