← Back to work

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.

∑
Source of the numbers. Every count on this page was measured at commit 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.

⇢
There is no shared password. Sign-in is Google, restricted to one owner, so the graph behind those pages is not open to visitors and never will be. Everything below the fold on this page was captured from a local instance running the seeded demo graph, which is fictional by construction.
The public product tour, showing a stepped walkthrough with keyboard controls.
Fig. 01: the public tour at /tour. Captured live, signed out, 28 August 2026.

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.

The Today workspace, showing a merged Needs You queue of overdue reminders, tasks and deadlines, with habit streaks and reconnect prompts below.
Fig. 02: Today. The Needs You queue merges reminders, open tasks and near deadlines into one ordered list instead of three competing ones.

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.

The capture palette open over the Today screen, with a single typed line parsed into labelled chips for person, event, tag and note.
Fig. 03: one line of capture grammar, parsed into chips before it is committed.

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:

ts
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;
The Who should I call screen with ten ranked contacts. The top result scores 3.20 and shows chips reading works there now +2.00, strength +0.66, via Met at Deep Learning Indaba +0.33, owes you one +0.20 and network proximity +0.01.
Fig. 04: a referral question, answered. The chips under each name are the terms of its score, not a summary of them.

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.

A person page showing a relationship strength dial reading 0.55 active, decomposed into closeness +0.27 and contact +0.28, beside a cadence target, quiet period and median gap, with eight logged interactions listed.
Fig. 05: the same decomposition on a person page. The dial reads 0.55 and states which half came from closeness and which from recent contact.

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.

The Correspondence screen listing ties worth reactivating with their peak and present strength, above an out-of-balance section showing unanswered inbound counts and the share of messages you sent.
Fig. 06: Correspondence. Each row states what the tie peaked at, when, and where it stands now.

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.

The Warm paths screen showing five routes to an organisation. A direct affiliation scores 0.90 and a single-hop introduction through a named colleague scores 0.66, each annotated with evidence chips.
Fig. 07: five routes to one destination, each hop inspectable, with the strongest opener offered rather than assumed.

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 Life map, a force-directed graph of 221 records and 385 connections focused on one person, with typed nodes coloured by category and lens controls for tie strength, career spine and hubs.
Fig. 08: the Life map. Lenses re-weight the same graph rather than fetching a different one.
The Chronograph, showing roles, projects, education, events and trips as parallel lanes across years from 2019 to 2027.
Fig. 09: the Chronograph. Evidence lanes make overlap visible, which is what a résumé has to flatten away.
The Atlas, a dark world map with clustered counts over cities across Africa, the Middle East, South Asia and East Asia, noting 22 places, 8 trips and 5 unmapped.
Fig. 10: the Atlas. It reports its own gaps: five records carry a place it could not resolve.

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 excluded

The 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 résumé tailoring workspace: job targets listed at left, a live rendered résumé document in the centre, and content controls at right offering auto-selection, template choice and wording emphasis.
Fig. 11: tailoring against one target. The document at centre is the live compile, not a preview of a saved file.

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.

A petition exhibit dossier reading This dossier satisfies its grammar, above a numbered exhibit list where each entry cites a specific publication or patent record.
Fig. 12: a dossier that validates. Each exhibit points at exactly one record, and the grammar states the limits it was checked against.

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.

The Import screen showing a four-step chain of custody from choose source through read safely and review evidence to commit or undo, with seven sources listed in states including staged, synced, rolled back, committed and discarded.
Fig. 13: import as a chain of custody. Every batch stays inspectable, and every state it has been through is on the record.

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.

The schema observatory, headed Relation intelligence without the hairball, showing 34 entity constellations grouped into career, content, job search, life and long tail, with 29 relation types listed in a keyboard-accessible index.
Fig. 14: the schema observatory. It reads no user data, and it is generated from the same type constants the API validates against.

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

0

test cases · 360 files · 0 skipped

0k

lines of production TypeScript

0

API endpoints · 3 of them public

0

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.

0

untested algorithm modules

0

uncited requirements

0

cyclic package dependencies

0

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.

The public manifesto page set in a serif typeface, headed A career move deserves more than a document rewritten from memory.
Fig. 15: the manifesto. Its promises are exported as a constant so tests can assert the public commitments still hold.

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.

✓
Check the work. Open the live site, follow the tour, read the manifesto, or see how this portfolio is built to the same standard. Treat the live application as current if this page falls behind.

Vitae 1.0.0 · commit 781f041 · measured 28 August 2026 · active development.

Next project

ahmdmshazly.me: this portfolio and its content studio

View→