Case study · Automation
Vitae: a life and career system of record
A typed, versioned personal graph that answers two questions deterministically: who should I call, and what do I say about myself for this job. Every score decomposes into the reasons behind it. No language model runs anywhere in the product.

Role
Spec author · product · core algorithms · full stack
Timeline
First ADRs 11 Jul 2026 · 1.0.0 · active development
Stack
React 19 · Vite · Express · PostgreSQL · Kysely · Tectonic LaTeX
Output
Private career graph · explainable ranking · deterministic document compiler
Vitae keeps a person's whole working life as a typed graph: nodes for people, roles, projects and interactions, and first-class edges that are records in their own right. On top of that graph sit two questions it answers the same way every time. Who should I call about this? And what do I say about myself for this job? Both answers arrive with their reasons attached, because every score is a closed-form calculation whose terms are the chips shown next to it.
781f041 (27 August 2026) with the command recorded beside it in backups/vitae-numbers-ledger.2026-08-28.md. Figures were photographed on 28 August 2026 against the seeded demo space, never real contacts. The repository is private, so this page carries the evidence instead of a link to the source.Open it before you read about it
The marketing surfaces are public and need no account: vitae.ahmdmshazly.me for the product argument, the tour for a keyboard-operable walk through each workspace as well as the manifesto for what the product refuses to do.

Two engines, one graph
The product has an episodic engine and a continuous one, and they share a single store. The episodic engine is the move: a job description goes in, and evidence-selected documents plus a warm introduction path come out. That is the part with urgency behind it. The continuous engine is the system: capture, relationship cadence, provenance and periodic proofs, running whether or not anyone is job hunting. One sells, the other retains, and neither works without the other because the move is only as good as the record it draws on.
Capture once. Connect always. Compile when the job asks.
The design rests on three findings that are cited rather than asserted. Weak ties move careers, shown causally by a randomised experiment across roughly twenty million professional-network users. Dormant ties are the highest-yield reconnections, and people reliably pick the comfortable contact over the valuable one, which is a bias the ranking is built to correct. And memory for your own accomplishments decays within weeks, which is why capture has to be cheap enough to do on the day rather than reconstructed from memory in a panic.
That last point is also why journals, trips and habits live in the same graph as roles and projects. They are not a second product bolted on. They are the evidence substrate the career story is assembled from, and a claim you can trace to the week it happened is worth more than one you can only assert.
A record that cannot quietly change
There are 34 node types and 29 edge types, and each of the 34 has a form descriptor, so no type exists that you cannot actually create. Every record, whatever its type, shares one envelope: identity, title, dates, status, sensitivity, source, confidence and the timestamps, with type-specific fields validated per type in a props object. Edges carry props too, which is the point. A relationship is a record with its own history rather than a pointer between two rows.
Every mutation writes an append-only revision row inside the same transaction as the write. That single decision gives the product its audit log and its undo substrate at once, and it means the answer to "when did this change, and from what?" is never reconstructed after the fact. Deletes are soft, with a 30-day restore window, and revision diffs name the field that moved rather than reporting that something in the record changed.

Six workspaces, one instrument
The application is organised as six workspaces rather than a dashboard of equal cards. Each one opens on a ruled trace that orients, then invites inspection, then a decision, then the action, with a single dominant destination rather than nine tiles competing for the same glance.
- Today keeps the agenda, the planner as well as the ledger of what changed.
- Network holds people, organisations, correspondence and the two intelligence lenses.
- Career runs applications, dossiers, the résumé workspace as well as letters.
- Explore offers the same graph as a map, a chronology and a geography.
- Data covers imports, duplicate resolution, exports as well as the trash.
- Settings carries identity and the system controls.
Capture is a keyboard grammar rather than a form. One line typed into the palette names a type, a person, an event, a tag and a note, and it parses into chips you can see before you commit. The cheapest possible path from "that just happened" to a typed record is the whole design goal, because the research says the memory is gone within weeks.

Who to call, with every reason visible
Ranking contacts is where most products would reach for a model and hand back an opaque ordering. This one is a weighted sum, and the weights are a constant you can read:
export const WHO_TO_CALL_WEIGHTS = {
affiliation: 2.0, // works there now, or used to
topic: 1.6, // evidence matches what you asked about
strength: 1.2, // the tie itself
geo: 0.6, // same city, then same country
reciprocity: 0.4, // the favour ledger, both directions
graph: 0.8, // personalised PageRank over typed edges
pinned: 1.0,
} as const;
Explainability here is enforced as a correctness property rather than offered as a feature. The chips on screen are the terms of the sum, so a ranking that could not be explained would be a failing test rather than a design compromise. The top result in Fig. 04 scores 3.20 because it is 2.00 for working at the target organisation, 0.66 for the tie, 0.33 for the topic evidence, 0.20 for an unsettled favour as well as 0.01 for graph proximity. You can check the arithmetic yourself, which is the entire point.
Relationships decay on a curve, not a calendar
Tie strength is two-dimensional in the literature: self-reported closeness plus interaction intensity. The intensity term is the conditional intensity of a Hawkes-type self-exciting point process with an exponential kernel, which is the standard model for bursty human communication rather than an analogy borrowed from one.
R(t) = Σ w(kind_i) · exp( -(t - t_i) / τ ) τ = 90 days
strength = clamp( 0.45·(closeness/5) + 0.55·min(R/3, 1), 0, 1 )Two refinements matter more than the formula. Distinct contact days beat raw volume, so twelve messages in one afternoon do not counterfeit a year of contact. And the decay threshold is per tie, set from multiples of that relationship's own median gap between contacts, because a quarterly correspondent and a weekly one are not both overdue at sixty days. A mentor you speak to twice a year is not drifting; a colleague silent for the same period is.

Reconnection priority then transfers a result from the spaced-repetition literature, where optimal review intensity under exponential forgetting is proportional to one minus the recall probability. Here the quantity being forgotten is a relationship, so a tie you rated close but have not touched in a year outranks a weak tie you spoke to last week. That is precisely the correction the dormant-tie research calls for, and it is why the surface is ordered by what a tie once was rather than by what feels comfortable to open.

This screen deliberately introduces no new mathematics. A tie's history is re-read with the same kernel that scores it today, evaluated at each past interaction, so the peak and the present come from one model. Two screens can therefore never disagree about what strong means, which is the kind of consistency that is cheap to design in and nearly impossible to retrofit.
Warm paths, scored by their weakest hop
The second lens answers reach rather than contact. A path's score is the product of its hop strengths, which is the property that makes the ranking behave: a long chain of lukewarm acquaintances scores below a short strong one automatically, with no penalty term bolted on for length. Ties break deterministically by node identity, so the same graph always produces the same route.

One graph, three ways of looking at it
The same records are navigable as a neighbourhood map, a chronology and a geography, because the question you arrived with determines which one is legible. Who surrounds this project is a graph question. What was I doing that year is a timeline question. Where does my work actually happen is a map question. None of the three holds data the others lack.



The résumé as a compile target
A résumé here is not a document you maintain. It is the output of compiling a selection of evidence against a specific job description, which means there is no master copy drifting out of date in a folder. The job description is parsed with BM25F, weighting terms by the section they appear in and saturating once rather than once per section, so a keyword repeated nine times is not treated as nine times more important. Multi-word skill aliases are matched by a token-level Aho-Corasick automaton in a single linear pass.
Choosing which bullets survive a one-page budget is then a budgeted submodular maximisation, the formulation that made extractive summarisation tractable with a guarantee:
F(S) = Σ_k w_k·( 1 - e^(-λ · cov_k(S)) ) + μ · Σ quality(a)
subject to: Σ lines(a) ≤ budget (knapsack)
bullets per owner ≤ max (partition)
pinned forced in, blocked excludedThe saturating exponential is doing the real work. The third bullet proving Python adds far less than the first, so the optimiser spends its remaining lines on uncovered ground instead of stacking redundant proof of the same skill. Selection is cost-benefit greedy, compared against the best single atom, which is the safeguard that preserves the approximation bound. Identical inputs produce an identical proposal, every time.

The compiler is treated as an attack surface
Every piece of user content that reaches LaTeX passes through one escaping layer first, and that module is marked security-critical for a reason. It is a single-pass per-character map, so a character introduced by an escape, such as the braces in \textbackslash{}, is never reprocessed. Sequential String.replace calls are unsafe for exactly that reason, and the property-based fuzz suite asserts over arbitrary Unicode that the output never leaks an active control sequence.
The build service around it is hardened as a supply chain rather than a container. The base image is pinned by digest, because a tag can be moved. The Tectonic tarball is downloaded, verified against a recorded SHA-256 and only then unpacked, because piping curl into tar executes whatever the URL happened to serve that day. Dependencies resolve from a committed lockfile.
The detail worth stealing is the calibration gate. At image build time every template is compiled once with the network available to warm the font and package cache, then compiled again under RUN --network=none. If anything would still need to reach the network at runtime, the image fails to build. That converts a whole class of production failure, the compile that works locally and dies on a DNS error in front of a user, into a build error nobody can merge past. At runtime the service takes HMAC-signed requests compared with a constant-time equality, runs Tectonic --untrusted with no shell escape in a per-request working directory, and honours SOURCE_DATE_EPOCH so re-exporting the same résumé produces a byte-identical PDF.
Documents that cite their sources
The résumé pipeline generalises into dossiers: a named single-subject selection of evidence whose claims cite specific records. Three audiences ship with their own section grammars, driven by data rather than by branching code: a promotion review, a set of petition exhibits as well as a background history. Compiling resolves every citation against the live graph and appends a provenance appendix carrying each source, its confidence and its revision trail.

The appendix keeps three citation states strictly apart: live, withdrawn as well as never existed. Collapsing the last two would tell a reader that a citation had been invented when in fact it had been retracted, and in an immigration petition or a promotion packet that distinction is the difference between an amended claim and a fabricated one. When a dossier does not satisfy its grammar the screen says which section failed and why, and it still renders the half that is already correct rather than refusing to show anything.
Whose degree is this?
This is the subtlest problem in the codebase and the one I am most pleased with. Imports bring in other people's roles, degrees and awards, because that is what a contact export contains. So every record of this type stops being the same set as every record of this type that is mine, and confusing the two puts somebody else's credentials on your résumé.
Types that are meaningless without a person declare the edge that owns them, so a role is held by someone and a certification is earned by someone. Organisations, skills and locations declare no owner at all: they are shared vocabulary belonging to nobody. Crucially the ownership rules are derived from the form descriptors rather than maintained beside them, so the two cannot drift apart as types are added.
Two invariants close it off. Ownership is total, so an orphaned record is attributed to a reserved anonymous person rather than left dangling, because "not mine" and "nobody has said" must not collapse into the same state. And the résumé is single-subject: with no self anchor it renders an empty document rather than a plausible one assembled from whoever happened to be in the graph. An audit command reports unowned and contested records, so the invariant is checkable rather than merely intended.
Imports that converge instead of forking
Re-importing the same address book six months later is where personal databases usually go to die, quietly doubling every contact. Here each importer synthesises an identity key from whatever the source genuinely identifies a row by: a vCard UID, an iCal UID, a bookmark URL, a profile slug or an email address. A matched row becomes a proposal with a per-field diff, accepted or rejected field by field, never applied silently. Rollback is per record and guarded by revision, so undoing an import cannot clobber an edit you made afterwards.

Duplicate detection uses Fellegi-Sunter probabilistic record linkage, with agreement and disagreement weights taken as log-odds. Two refinements make it usable on real data. Approximate comparators let a near-match on a name contribute partial agreement rather than a binary verdict, and a missing field contributes nothing at all rather than counting as disagreement, since an absent middle name is not evidence that two people differ. Auto-merge never happens. Both probable and possible matches go to a review queue, because an incorrect merge destroys two records and their history to save one click.
A design system written as bans
The visual language is a matte near-black instrument at rest. The most unusual decision is that there is no default accent hue: the action colour is paper white, which keeps colour available to mean something. Where colour does carry meaning it is never the only signal, so every outcome also has a word, a glyph or a shape. Type is a variable sans for prose and a mono for anything you might need to read precisely, which is to say data, dates, counts, provenance and keycaps.
Motion is five variants sharing one easing curve. Entrances are opacity and at most four pixels of rise; exits are opacity only and shorter, because a departing element does not deserve the attention an arriving one earns. List entrances stagger but cap at five positions, so a fifty-row table does not perform a two-second cascade. The rule the whole system rests on is a question rather than a value: what state change does this motion report? If there is no answer, there is no animation.
None of that would survive contact with a deadline if it were only written down, so it is machine-checked. A gate fails the build on gradients in chrome, on a second easing curve, on durations off the scale as well as on a handful of specific anti-patterns. Deliberate exceptions live in an allowlist with a written reason each, which means an exception costs a sentence of justification instead of drifting in silently.

Documentation that fails the build when it lies
Thirteen generators extract machine-readable truth from the source into a set of JSON artifacts: the routes, the endpoints, the components, the design tokens, the database schema, the algorithms as well as a traceability register. Prose documentation cites requirement identifiers, and a gate fails if a cited identifier does not exist or a registered requirement is cited nowhere. The current state is 131 registered requirements, 920 citations across 244 files, zero uncited and zero unregistered.
Two details raise this above bookkeeping. The register distinguishes evidence honestly: a citation in production source shows a behaviour exists, a citation in a test shows it is protected as well as a citation in a document showing only that somebody wrote it down. And the checkers ship their own self-tests, with a further gate that fails if the local verification script drifts out of step with what continuous integration actually runs. A checker nobody checks is a checker that silently stopped matching years ago.
What was measured
test cases · 360 files · 0 skipped
lines of production TypeScript
API endpoints · 3 of them public
web routes · 61 lazy · 18 prerendered
The test suite is 2,476 cases across 360 files, none skipped and none absent from continuous integration, against 89,365 lines of production TypeScript and a further 44,921 lines of test code. That ratio is roughly one line of test for every two lines of product. Six jobs run on every push: dependency review, quality, build, API integration against a real PostgreSQL, LaTeX calibration as well as a full end-to-end suite.
The shape of the testing matters more than the count. Property-based tests fuzz the security-critical paths. Metamorphic tests assert that transformations which should not change a result do not, so reordering candidates must not reorder the ranking. Mutation testing is aimed at the five highest-consequence modules, which measures whether the tests would actually notice a bug rather than whether they run. Forty-three state-matrix files cover the pending, error, empty and first-use state of individual screens, and accessibility is asserted in both unit and browser tests rather than audited once and declared done.
untested algorithm modules
uncited requirements
cyclic package dependencies
AI provider dependencies
The zeroes are the ones I would defend in an interview. Twenty algorithm modules, 136 exported symbols, none untested, none impure as well as none unused by the product. And the coverage floors are ratchets: the minimum number of test files and assertions may only be raised, while the permitted number of fixed sleeps and weak truthiness checks may only be lowered. The notes beside them are unusually candid about their own history, recording a published count that was wrong for years because the search that produced it never matched a second syntax.
Private by construction
Authentication is default-deny: three endpoints out of 153 are public, and everything else requires a verified identity before the router is reached. There is no telemetry, stated as a commitment rather than left as a default. And the constraint that runtime behaviour must not depend on an external AI provider is verifiable rather than promised, because no such dependency appears in any manifest in the repository. Even the outreach message composer is a deterministic matrix of tone and ask-kind filled with real relationship facts.

Export is the anti-lock-in guarantee and it is deliberately generous. Every collection comes out as JSONL preserving record identity, alongside a Markdown vault of one note per record with wiki-links for edges, so the archive is readable with no software at all. A manifest carries the schema version, the counts as well as SHA-256 checksums. Sensitivity exclusion is transitive: excluding a record also excludes the edges touching it, its revisions and everything dependent on it, because an export that omitted a private record but kept the edges pointing at it would leak exactly what it claimed to withhold.
What this page will not claim
Every case study is an argument made by its author, so here are the limits of this one, stated plainly:
- It is not local-first or offline-capable. There is no service worker, no client database and no sync layer. Local means self-hosted: you run the PostgreSQL. Say single-operator and no telemetry, not offline.
- The hosted multi-tenant track is designed, not operating. It is gated behind a flag and its own documentation forbids accepting customer data until isolation, billing, deletion and backup controls are verified.
- The one-page and keyword-coverage rules are product gates, not measured outcomes. They are thresholds the compiler enforces, and this page does not present them as a study result.
- The repository is private, verified on the day of writing, so nothing here can be checked by reading the source. That is why the figures and the ledger exist.
- 143 commits is not the age of the project. History was squashed at the 1.0.0 baseline; the architecture decision records date the work to 11 July 2026.
- Requirement coverage is partial. Of 131 registered requirements the register itself marks 38 as proven by a direct test citation, and roughly a hundred endpoints are not yet described in the OpenAPI document. Those are the project's own self-critical numbers, and I would rather quote them than omit them.
The demo graph behind every figure above is fictional, generated by a seed script, and the screens were photographed against a local instance. No real contact, message or document appears anywhere on this page.
Vitae 1.0.0 · commit 781f041 · measured 28 August 2026 · active development.
Next project