My Formatter Broke an Immutable Resume by Formatting It Correctly

Part 2 of The Portfolio Is an Interface.

I froze a resume snapshot, recorded its SHA-256 hash, and wrote a validator that rejected later edits.

Then my formatter edited it.

Prettier did not corrupt the Markdown. It did not delete a sentence, reorder a section, or hallucinate a new skill. It removed twelve blank lines. The document looked cleaner, the formatter was happy, and the snapshot was no longer the artifact I had frozen.

This is the annoying thing about immutability: a change does not become harmless just because every human reader agrees that it preserves meaning. The bytes changed. The promise was about the bytes.

The repository's validator is designed to report that exact mismatch. My formatter, meanwhile, would like credit for a job well done. (Please hold your applause.)

Why I freeze resume snapshots at all

My portfolio starts from one bilingual resume/master.yaml. That file evolves as links, projects, and descriptions change. An application does not.

When I prepare a resume for a specific target and language, the repository creates a dated snapshot directory containing three files:

  • resume.yaml: the selected structured content.
  • resume.md: the rendered document.
  • meta.yaml: the target, language, notes, and hashes.

The directory name follows YYYY-MM-DD-<target>-<ko|en>. A revision is supposed to create a new directory rather than edit the old one.

That distinction is the reason snapshots exist. Months later, I want to answer a boring but important question: “What exactly did I submit?”

If the snapshot quietly follows the master, I cannot answer it. I can only show today's resume wearing an old date badge. That is not history; it is cosplay.

Round 1: turn “frozen” into a checkable claim

The repository's freeze command hashes the two content files:

const SNAPSHOT_FILES = ["resume.yaml", "resume.md"];

const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");

export function freezeSnapshot(dir) {
  const metaPath = path.join(dir, "meta.yaml");
  const meta = parse(fs.readFileSync(metaPath, "utf8")) ?? {};

  if (meta.hashes) {
    throw new Error(`${dir}: already frozen — snapshots are immutable`);
  }

  meta.hashes = Object.fromEntries(SNAPSHOT_FILES.map((file) => [file, sha256(path.join(dir, file))]));
  fs.writeFileSync(metaPath, stringify(meta));
}

Validation recomputes both hashes and compares them with meta.yaml. It also rejects a new unrecorded file in the snapshot directory, so resume-final-really-final.md cannot stroll in after the freeze and pretend it was always there.

The tests cover the failure states directly. One freezes a temporary snapshot, appends tampered\n to resume.md, and expects a modified after freeze error. Another adds resume-final.md after freezing and expects an unrecorded file error. A third tries to freeze the same snapshot twice and expects the command to refuse.

That is a useful pattern: do not test an immutability guard only with an artifact that nobody tried to mutate. Give the fixture a small crime to detect.

Round 2: the hash mismatch was the success case

The real incident was much less cinematic than tampered\n.

A formatting pass added blank-line changes to one frozen resume.md. The file's recorded SHA-256 began with 03ef45da. The formatted file began with 69601920.

Those prefixes are not aesthetically different versions of the same hash. They are a mismatch, which is exactly what the validator was built to report.

The important conclusion is not “hashes are fragile.” They are supposed to be.

If I had normalized Markdown before hashing and normalized it again before validation, the blank-line edit might have disappeared from the comparison. That would make the snapshot tolerant of formatting changes, but it would also change the question being answered. Instead of “Are these the exact bytes I froze?” the validator would ask “Do these bytes normalize to something similar?”

Both can be valid contracts. Mine was the first one.

If the validator reports that mismatch, it has not broken the workflow. It has kept the workflow honest.

Round 3: detection is not prevention

The easy conclusion is to celebrate the detector. It can catch the edit, after all.

But a validator that would repeatedly catch an authorized tool rewriting protected files is only half a design. The reader knows the rule; the writer still has permission to violate it.

In this repository, Trunk runs formatting during the normal commit workflow. Prettier sees Markdown under resume/versions/ and does what it was invited to do: it formats Markdown. The formatter has no concept of a hash-sealed artifact unless the repository gives it one.

The fix was five lines in .trunk/trunk.yaml:

lint:
  ignore:
    - linters: [prettier]
      paths:
        - resume/versions/**

Then I restored the snapshot's resume.md to the bytes whose hash was already recorded. In commit 650eadb, that restoration removed the twelve formatter-introduced blank lines, bringing the file back to the 03ef45da… hash stored in meta.yaml.

Notice what the configuration excludes: Prettier on the frozen snapshot path. It does not disable Trunk for the repository. It does not declare every resume file untouchable. It does not bypass the validator.

The narrow fix matches the narrow invariant.

This is where broad “just ignore the folder” fixes become tempting. They are also how a protected artifact quietly stops receiving checks that still matter. The problem was one mutating formatter, so the configuration removed one mutating formatter from one path.

Round 4: format before the boundary, never after it

The obvious follow-up is: should an immutable Markdown file be ugly forever?

No. It should be formatted before freezing.

The lifecycle I want is a clean destructive boundary:

  1. Generate or edit resume.yaml and resume.md.
  2. Format and review them while they are still working files.
  3. Freeze the snapshot and write the hashes.
  4. Treat every later revision as a new snapshot.

Formatting belongs to step 2. Hash validation belongs to step 4. The current freeze command does not enforce step 2; that ordering is the operating rule I would keep around it.

Moving the formatter after the freeze is like laminating a signed document and then trimming the edges because the paper looked uneven. You may have improved the rectangle. You did not preserve the signed artifact.

This sequencing rule generalizes beyond resumes. Generated lockfiles, signed manifests, migration records, release artifacts, and evidence bundles all have a point after which “cleanup” becomes mutation. The tool does not need malicious intent. It only needs write access on the wrong side of the boundary.

What is actually frozen here

The promise is precise, and therefore smaller than “nothing in the directory can ever change.”

The current freeze records hashes for resume.yaml and resume.md. meta.yaml carries those hashes, but its metadata is not itself hash-sealed. The architecture decision accepts that limitation and uses Git history as the backstop for metadata changes.

The validator also permits .DS_Store rather than treating it as snapshot content.

So the guarantee is not “the directory is a cryptographic vault.” It is this: the two submitted content representations must keep the exact bytes recorded at freeze time, and additional unrecorded content files are rejected.

That is enough for the question I need to answer. It would not be enough for a signed legal archive or a hostile storage environment. Different threat model, different tool.

The lesson I am keeping

There are two sides to an integrity rule:

  • A reader that detects when protected state changed.
  • A writer policy that keeps routine tools from changing it accidentally.

My hash validator handled the first side. The Trunk exclusion supplied the second.

Without the validator, the formatter's edit would have looked harmless. Without the exclusion, every future formatting pass could recreate the same failure.

If you have a directory named snapshots, releases, fixtures, or evidence, check which tools can still write there. Do not stop at “the hash test passes today.” Run the formatter in dry-run mode against the path and see whether it proposes a change.

If it does, decide which contract you actually want:

  • Normalize before comparing, if semantic equivalence is the promise.
  • Freeze exact bytes and move the formatter before the boundary, if historical reproduction is the promise.

I chose exact bytes. Prettier chose twelve blank lines. The hash got the final vote.