Source available · Reference build · 2026
Stemma
A family tree is not a tree — cousins marry, and the same ancestor legitimately appears twice in one chart. Stemma stores the graph, enforces it in Postgres, and proves the privacy boundary with 118 checks that are themselves proven able to fail.

- Role
- Data model, database rules, access policies, three extensions, the web app, provisioning and branding — all of it
- Year
- 2026
- Languages
- English
- Stack
- DirectusTypeScriptPostgreSQLReact RouterVue 3Docker
Every hobby genealogy app starts with persons(id, name, mother_id, father_id), and that schema has no answer for adoption, step-parents, same-sex parents, donor conception, surrogacy, foster placement, or a child with two biological and two adoptive parents on record. All of those are ordinary. The shape is wrong on day one and every row has to change later.
A directed acyclic graph, not a tree
Cousins marry. Pedigree collapse puts one ancestor in two positions of the same chart, and in endogamous communities that is the norm rather than the edge case. So relationships are edges — GEDCOM X's shape rather than classic GEDCOM's FAM record — and the renderer handles one person appearing several times in the same diagram. Couples, not marriages: many unions were never marriages and produced children regardless, so marriage is an event on the couple and its absence is not a gap.
The rules that matter are in Postgres
A rule enforced in application code holds for the clients that go through that code. A rule enforced by the database holds for every client there will ever be. No person may be their own ancestor — a trigger running a recursive CTE upward from the proposed child. A couple is unordered, enforced by a unique index on the sorted pair. Twenty-nine constraints, fifty-two triggers and thirty-six indexes, none of which Directus's schema sync would have carried.
The acyclicity trigger was beaten, then fixed
Two sessions under READ COMMITTED each inserted half of a cycle, each checked against a snapshot that did not contain the other, and both committed. The suite proved it by racing two real connections. The fix is a per-tree advisory lock taken before the walk. The same night produced a second rule: a trigger that writes to its own table must name its columns, because the events visibility trigger fired on its own write and recursed until the stack ran out.
A date is five columns, never one
Genealogical dates read about 1850, before 1900, between 1852 and 1855, 24 Feb 1750/51. The verbatim original is kept and never parsed away; four derived columns carry the range it could occupy, the qualifier and the calendar. Sort on the earliest, display the original. A hook parses one into the other and refuses to guess — 12/03/1889 is March or December depending on which side of an ocean the clerk stood, so it answers phrase and keeps the sentence.
Conflicting evidence is a feature, not a bug
Two censuses give two birth years. The naive app overwrites. This one keeps both assertions with their citations and their confidence on GEDCOM's QUAY scale, and records which was concluded — the Genealogical Proof Standard asks for exactly that. A disproven parent line stays in the database as the record of a mistake, excluded from the public filter and from the export.
Living people are a legal problem
Publishing a living person's birth date and mother's maiden name hands over two of the three standard identity-verification answers. Two access paths, deliberately separate: two flat indexable booleans for the public, membership for contributors. And a row about a living person is any row that names them — the edge to their dead mother carries her maiden name, the census they appear in as a child, the street known only from their residence. Nineteen permissions, each asserted in both directions.
A suite that is proven able to fail
A hundred and eighteen checks that sabotage a live instance and roll it back: the database refuses a four-generation loop, a two-generation loop, a self-parent, a swapped couple — while still permitting pedigree collapse, which is the check that tells a DAG from a naive seen-this-ancestor guard. Every check is proven falsifiable by breaking the thing it watches. A check that could not run reports as a failure, never a pass, because an anonymous visitor cannot see a living person is trivially true when the policy grants nothing at all.
GEDCOM 7, with the families derived
GEDCOM makes a family the node and this schema deliberately has no such row, so families are derived at export time exactly as the descendant chart derives them at render. Lineage becomes PEDI, so an adopted child exports two FAMC links instead of losing one. The endpoint reads through Directus with the caller's own permissions, so there is no second copy of the privacy boundary in the exporter — a member's file has the living person in it and an anonymous one does not.
Four drawings, and the numbers behind them
Pedigree, fan, an illustrated tree and a descendant chart that lays out couples rather than people, because otherwise whoever married in never appears. Facts are carried by shape and never by colour alone — the NSGC standardized pedigree nomenclature, so it survives a photocopier and every kind of colour blindness. The smooth pan was measured, not reasoned about: will-change: transform, textbook for promoting a layer, made p95 fourteen times worse on a chart-sized layer and was removed.
The demo is the same app, with the data frozen
The live link is not a hosted instance — it is the same React Router app built with ssr: false and prerendered against a snapshot of the three demo families, so it is flat files with no server, no database and no sign-in. To stop the snapshot drifting from the real thing, both read the same query module: the routes at runtime with the member's cookie, the snapshot script at build time. It names the three demo slugs in a literal rather than exporting what it finds, so the author's own family — which lives in a gitignored seed in the same database — is excluded by construction rather than by remembering to.
Contrast is a check, and the palette is computed
The first palette failed WCAG AA in forty-five places, and the worst offenders were the dates — which is four fifths of what a genealogy interface shows. A browser-driven check now resolves the colour actually painted, in three themes across four layouts, and fails the build. The admin's own colours are measured the same way, which caught a blue that reads at 2.69:1 on the dark surface. Categorical hues cap at three: a fourth fails the normal-vision separation floor, so a fourth line goes neutral rather than get an invented colour.
Source available · Reference build