Contact / dossier data model
A shared model for people-dossiers, used by this repo (Incline community directory) and
the querystory-sales-kit (event contacts). Goal: keep the rich prose we already write, but
make the structured facts queryable so we can render views (by relationship, by tag, by
event) without maintaining a second copy of the data by hand.
The problem we’re solving
What we write today is structured markdown: a fixed key-value header, a semi-fixed set of
## sections, and freeform prose underneath. Alongside it we keep a separate hand-written
JSON index (people.json, attendees.json). Two copies of the same facts = guaranteed
drift. Sales-kit already shows the failure mode: profiles carry “⚠️ the event JSON is stale”
notes because the structured side and the prose side diverged.
So the design question isn’t “JSON or markdown” — it’s which one is the source of truth,
and how does the other get generated.
Decision: frontmatter is the source of truth; the index is generated
One file per person. The structured facts move up into YAML frontmatter. The prose stays
in the markdown body. A build step emits the JSON index from the frontmatter — never
hand-edited. This is the standard static-site pattern and it dissolves the drift problem:
there is exactly one place each fact lives.
---
name: Erik Fair
slug: erik-fair
groups: [ski-way-villas] # a person can be in several
relationship: neighbor
unit: "316 Ski Way"
confidence: confirmed
captured: 2026-07-27
links:
linkedin: https://www.linkedin.com/in/erikfair/
tags: [netbsd, dns, ntp, infosec, hoa-board]
hooks: # short, structured — the "why we care" bullets
- "ran NetBSD in the 90s = the OS he builds"
- "Apple Hostmaster ↔ Shapor ran DNS at Google"
roles: ["HOA President (Diamond Ridge OA)"]
---
## Background
Long-form prose lives here, exactly as we write it now…
## Networking notes
…
Why frontmatter and not “parse the **Captured:** header block”: parsing prose is brittle
(every author formats it slightly differently, and it breaks the moment someone edits a
line). Frontmatter is a real typed key-value store the generator reads for free. The header
block we write today simply becomes the frontmatter — same facts, moved above the ---.
What’s structured vs. prose (the boundary)
- Frontmatter (queryable): identity (name/slug), membership (
groups,relationship),
tags,confidence,captureddate,links, and the shorthooks/roleswe filter and
sort on. Anything a view needs to group, filter, or badge by. - Body (prose): headline, about/verbatim, experience, the dossier narrative, approach
notes. Anything you read rather than query. Never duplicate a frontmatter fact in prose;
link/derive instead.
Rule of thumb: if a future “show me all X” view would need it, it’s frontmatter. If it’s a
paragraph you’d read to a spouse over coffee, it’s body.
Views come from querying frontmatter
Once facts are in frontmatter, views are trivial and need no extra data:
- By relationship / group (“Ski Way neighbors”, “LTS friends”, “landlord”) → group by
groups. - By tag (“who’s in infosec?”, “who’s fintech?”) → filter
tags. - By confidence → badge/sort on
confidence(confirmed / likely / not-found / pending). - Sales-kit adds event views (“attendees of CDO Vision”, “speakers”) via an
eventsfield
on the person, or an event doc that references slugs.
Two rendering surfaces, same data:
- Server-rendered (Hugo taxonomies):
groupsandtagsas taxonomies give section
pages for free, no JS. Good default for the directory landing. - JS viewer: consumes the generated
index.jsonfor instant client-side filter/search
across everything. Same static-hosting story — it’s just a JSON file + a script in the
bucket. Add when we want live filtering; not required for v1.
Generating the index (no second source of truth)
- Incline (Hugo): a custom output format emits
index.jsonby ranging over pages and
dumping their frontmatter. Regenerated on everyhugobuild. Zero hand-maintenance; the
existingpeople.jsongets deleted once the viewer reads the generated file. - Sales-kit (not Hugo today): a ~30-line script (
build-index.mjs) that reads the
frontmatter of everyfull-profiles/*.mdand writes the index. Same schema, different
generator. Event-level facts (agenda, venue) stay in a hand-authoredevent.json— that’s
genuinely separate data, not a duplicate of the person prose.
Shared schema, per-repo extensions
Both repos use the same core person fields above. Each adds what it needs as optional fields,
so one viewer/renderer can serve both:
- Incline:
unit,owner_since,roles(HOA titles),occupancy. - Sales-kit:
events: [slug],title,company,seniority,shared_history,
outreach_status.
Unknown fields are ignored by the renderer, so extending one repo never breaks the other.
Migration path (incremental, non-breaking)
- Add frontmatter to each
*.md(lift the header block up); leave the body untouched. - Add the generator (Hugo output format here; script in sales-kit) → produces
index.json. - Point the directory view at the generated index; delete the hand-maintained
people.json. - Optional later: the JS viewer for client-side filtering.
Do this one file at a time — a profile without frontmatter still renders fine as prose, so
there’s no flag day.
Why not the alternatives
- Keep two hand-maintained files: the status quo; drift is not a maybe, it’s a when.
- Pure JSON, prose in a string field: kills the thing that makes these dossiers good —
readable, diffable, git-blame-able narrative. Editing a paragraph inside JSON is misery. - Parse the prose header block into structure: brittle, format-dependent, breaks on edit.
Frontmatter is the same facts in a format built to be read by machines. - A database: overkill for O(50) contacts; loses git history, offline editing, and the
“it’s just files in a bucket” hosting model.