Roleframe exists because of an interview I failed. After a long job hunt I finally landed a system design interview, and the question was one I had never worked on: design a document editor. I did my best, there was no offer, and the problem would not leave me alone.
So I learned it properly. How editors record every change. How layout engines decide where things go. How professional tools are built under the hood. Somewhere in the middle of that I looked back at the resume builders I had used during my own job hunt, and I finally saw what they are.
Strip the marketing off almost any resume builder and you find the same shape: a schema and a template renderer. Fixed fields on the left, a static preview on the right, rules about what your resume is allowed to look like. Want a section the schema does not have? Too bad. Want two entries side by side? Not your call.
That shape is not an accident. A form is the part you can build in a weekend. A real editor, where you drag sections around, control the layout, and still get a clean multi-page PDF at the end, is the hard part. Every builder I used routed around it. And after learning editors from the inside because of one failed interview, I held exactly the knowledge needed to build the part everyone skips. So that is what Roleframe became: a real drag-and-drop resume editor, with AI tailoring sitting on top of it rather than replacing it.
Here is how the editor actually works. Stack, for context: Next.js with the App Router, React, TypeScript, Tailwind, Postgres with raw SQL and no ORM, and background jobs on a task runner, and a custom event sourcing engine written from scratch.
You cannot render a log. The event log is the source of truth, but React needs a current value, so every append materializes one. The part worth talking about is that the materialized shape is chosen for rendering, and rendering wants something quite different from storage.
Almost nothing in it is an array. Blocks are an object keyed by block id. Overlays are an object keyed by overlay id. Pages are an object keyed by page id, and inside a page its layout nodes are an object keyed by node id. The only ordered list anywhere in the model is the list of child ids hanging off a layout node, which is exactly where "what order things appear in" belongs and nowhere else.
A component subscribes to one id. A block selects its own entity by id and gets a stable reference back. Editing a different block does not change that reference, so it does not re-render, and I never had to think about it.
Put those same entities in an array and you get the opposite default: updating one entry produces a new array, every consumer sees a changed reference, and you spend the next month memoizing your way out of a problem the shape created.
One deliberate omission. The usual normalization recipe keeps a map of entities by id next to a flat array of all the ids. I dropped the array. Order already lives in the layout tree, so a second list would be a second source of truth for ordering: two places to update, and one of them drifting eventually. Where the answer already exists somewhere, do not keep a cached copy of it that can disagree.
The first big decision: documents are not saved as whole-state blobs. Every edit is an event, and the document is the result of replaying its event log.
I did not pick event sourcing because it is fashionable. I picked it because three problems I had to solve anyway fall out of it almost for free: Undo. When every change is a discrete, typed event, stepping backward is a first-class operation instead of a diffing afterthought. Sync. Sending small events over the wire beats re-uploading the whole document on every keystroke, and the server can reason about exactly what changed. Offline resilience. If the network drops, events pile up locally and flush when it returns. Nothing is lost, because nothing was ever "the one live copy in a textarea". Collaboration, when I get there. An append-only log with a strict per-document sequence is most of what multiplayer needs. It is not built yet, but the ordering guarantees already are, so it becomes a feature instead of a rewrite. AI that writes into the document, not around it. In progress. Because every edit is an event, the model can emit the same typed events a person would, building a resume from a blank page one step at a time. You watch it happen, every step is undoable, and an AI edit is auditable exactly like a human one, because it goes through the same log.
Blob-save gives you none of that. You get last-write-wins, a lost update the first time two tabs are open, and undo bolted on client-side with no server truth behind it.
An event log is only as trustworthy as the events you accept. Mine come in two flavors. Layout operations (move a node, wrap a container, delete a page) are fully typed variants. Content edits are JSON Patches, and every patch event declares the subtree it is allowed to touch. Not one global allowlist: the reducer that handles the event supplies the prefixes, so the permission lives next to the code that applies it.
A styles event physically cannot reach /data. A layout event cannot reach anything but a width. "Patch anything" becomes "patch this subtree", and the blast radius of a compromised client is whatever the narrowest reducer allows.
The op set is narrow too. Only add and replace exist. remove is commented out rather than deleted, so nobody re-adds it without reading why:
On the client, events apply optimistically through Immer, so the UI feels instant. The same event definitions run on both sides: the client applies them for responsiveness, the server applies them for truth.
And here is the hole that taught me an allowlist is not enough. Deletion was disabled, and deletion happened anyway. fast-json-patch runs with validateOperation: false, so a schema-valid { op: "replace", path: "/data/title" } with no value executed obj.title = undefined, and JSON.stringify then dropped the key entirely. A replace had become a covert delete of any allowlisted field, which is exactly the thing commenting out remove was supposed to prevent.
