Your Public Resume Should Be a Projection, Not a File Read

Part 1 of The Portfolio Is an Interface.

I wanted one resume file to feed everything: the Korean page, the English page, the printable documents, and the work pages on my portfolio. One source of truth, fewer places for a date or sentence to drift. Very tidy.

Then I wrote a test fixture with a fake phone number in it and made sure my public site could not see it.

No, this is not a dramatic story about accidentally publishing my phone number. I did not. The uncomfortable part was quieter: the master file was designed to hold more than the public site should ever expose, and a future field could become public simply because a convenient loader returned too much. Nothing would look broken. The site would just become helpful in exactly the wrong direction. (Thank you, data layer. Nobody asked.)

One source of truth is not one audience

My portfolio keeps its bilingual resume in resume/master.yaml. That master contains the authored material used by several public surfaces, while frozen submission snapshots live under a separate resume/versions/ tree. The repository's accepted architecture decision is explicit: the snapshots must never become website input, and the master must reach the site through one module.

The tempting implementation is also the shortest one:

const master = parse(fs.readFileSync("resume/master.yaml", "utf8"));
return master;

I did not ship that snippet; it is the implementation the boundary exists to prevent. It makes the file format the public API. Add a field to the file, and every consumer that spreads or serializes the object may inherit it.

This is where “single source of truth” can hide a category error. The source may be singular, but its audiences are not. A printable resume, a portfolio page, an Open Graph image, and a future RSS feed do not automatically deserve the same view of the data.

The master is the pantry. The public resume is the plate. Letting a route return the whole parsed object is less like serving dinner and more like wheeling the refrigerator into the dining room. (Efficient, technically.)

Round 1: pick fields instead of removing them

The public loader in my portfolio does not start with the master object and delete fields that look sensitive. It starts with nothing and picks approved keys:

export const ALLOWLIST = ["name", "label", "location", "languages", "summary", "email", "url", "profiles"] as const;

export function projectBasics(basics: MasterBasics): PublicBasics {
  const picked: Record<string, unknown> = {};
  for (const key of ALLOWLIST) {
    if (key in basics) picked[key] = basics[key];
  }
  return picked as unknown as PublicBasics;
}

That direction matters.

A denylist answers, “Which private fields do we know about today?” An allowlist answers, “Which fields have we deliberately approved for this audience?”

If a future edit adds phone, address, or a field nobody has named yet, an allowlist ignores it by default. A denylist has to remember to become smarter at the same moment the schema becomes larger. I do not trust future me to coordinate those two edits perfectly every time. Current me is not exactly undefeated either.

The code also builds the result key by key. There is no { ...basics } followed by cleanup. That is intentionally boring: an unlisted key has no path through the function.

Round 2: test the field you do not want

The projection test uses a master-shaped fixture with an extra phone key:

const masterBasicsFixture = {
  name: { ko: "홍길동", en: "Gil-dong Hong" },
  label: { ko: "개발자", en: "Developer" },
  summary: { ko: "요약", en: "Summary" },
  email: "test@example.com",
  url: "https://example.com",
  profiles: [
    {
      network: "GitHub",
      username: "test",
      url: "https://github.com/test",
    },
  ],
  phone: "010-0000-0000",
};

test("projectBasics drops keys outside the allowlist", () => {
  const result = projectBasics(masterBasicsFixture);
  assert.ok(!("phone" in result), "phone leaked through the projection");
});

Why include the unwanted field in the fixture at all? Because a privacy boundary should be tested with data trying to cross it. A fixture containing only approved fields can prove that the happy path renders. It cannot prove that an extra key disappears.

The real test also locks the allowlist itself. If somebody deliberately adds a public field, the expected list must change in the test too. That does not make the decision impossible. It makes the decision visible in the diff.

I like tests that are a little annoying in this specific way. Privacy changes should not slide through as innocent schema maintenance.

Round 3: one safe loader is useless if routes can walk around it

An allowlist only protects code that calls it.

If a page, metadata handler, or future feed reads resume/master.yaml directly, the beautifully tested projector becomes a decorative security feature. So the repository has a second test for the import boundary.

It walks the TypeScript and TSX files under src/, excludes the approved resume module and its tests, then fails if another file contains a direct master.yaml reference.

That test is deliberately narrow. It is not a theorem that proves no code can ever reconstruct the path indirectly. What it proves is concrete and useful: ordinary source files cannot introduce a direct reference to the master filename without making the gate fail.

This distinction matters. “The test passed” is not the same sentence as “private data cannot leak by any mechanism.” The first statement has a fixture and a reader. The second would need a much larger audit.

For the architecture in this repository, the two checks cover different failure modes:

  • The projection test asks whether an unapproved key survives the approved loader.
  • The import-boundary test asks whether a normal site file bypasses that loader with a direct master reference.

Neither substitutes for the other. One locks the shape; the other locks the route to that shape.

Round 4: customization may reorder claims, not invent them

The same boundary shows up in a less obvious feature. My resume pages accept a ?keywords= query so a reader can bring relevant skills and highlights to the front.

That sounds dangerously close to generating a custom resume from URL input. It is not.

The keyword layer may reorder existing skills and highlights. It may not add, remove, or rewrite a claim that is absent from the master. The tests compare the contents before and after ordering and keep the unmatched tail in its authored order. Unknown keywords are reported to the reader instead of silently turning into content.

This is the same design principle in another costume: input may select a view; it may not widen the facts available to that view.

The public site is an interface over authored data, not a permission slip for every consumer to reinterpret it.

What this boundary does not solve

The allowlist is not a replacement for reviewing the content inside approved fields. If summary itself contains something that should not be public, the projector will faithfully publish it.

The current allowlist covers basics, not every nested resume section. The accepted decision passes work, projects, skills, and activities through because their content was authored from public sources. If that content policy changes, the present basics test will not detect it; those sections will need their own projection rules.

The boundary also does not make every resume entry suitable for every surface. The repository already has a separate document view that filters entries marked hideFromResume, while the broader work page may still render them. Different audiences continue to need different projections.

And the boundary does not prove that the master is private because its filename says master. Repository visibility, build artifacts, logs, and deployment inputs are separate questions. This article is about one precise guarantee: public site code receives the basics object as an explicitly selected shape through one normal path.

Precision makes the promise smaller. It also makes the promise testable.

The lesson I am keeping

I used to hear “single source of truth” as the end of a data-design conversation. Now I hear it as the beginning of an access-design conversation.

Once several surfaces depend on the same rich source, give each audience a projection. Start that projection empty. Pick fields deliberately. Test it with a field that must disappear. Then make bypassing the projection loud enough to stop a routine change.

The code is not sophisticated. It is a constant, a loop, and two tests. That is exactly why I trust it more than a clever privacy abstraction spread across every route.

If your public page reads directly from the richest document in your project, try adding a fake sensitive key to a test fixture. Then follow it all the way to the rendered object.

If the key survives, you have found the next boundary to build. If it disappears, make the test tell you exactly why.

The implementation and tests quoted here live in a private source repository, so I have reproduced the relevant parts instead of giving you a link that cannot open.