name: v0.x-to-self-certifying
description: Migrate off the global .forge/checksums.json manifest onto self-certifying generated files — every Tier-1 file embeds its own content hash (forge:hash=<sha256>) in the DO-NOT-EDIT header, so the pristine check travels WITH the file through clones, branches, and partial commits. The migration is automatic on the next forge generate / forge upgrade; this skill explains what happens and how to resolve the one loud failure mode (provenance-unknown files).
relevance: migration
Self-certifying generated files (the checksums.json manifest is dead)
1. Why the manifest died
.forge/checksums.json was shared mutable state OUTSIDE the files it
described. The fatal incident class (kalshi fr-9a54388f0b, P0): two work
lanes in one checkout → the manifest got committed from the WIP lane →
on a clean clone of green HEAD, committed Tier-1 files matched no
recorded hash → forge generate hard-refused. A green tree that could
not reproduce itself. Plus: every regen rewrote the manifest, producing
pure-bookkeeping commits and constant merge conflicts between parallel
agents.
2. What replaced it
Every Tier-1 (regenerated-every-run) file now carries its own certification inside the existing generated header:
// Code generated by forge. DO NOT EDIT.
// forge:hash=3c0d1f9e… ← sha256 of the file's bytes, excluding this line
The pristine check is purely local: recompute the hash over the file's bytes (CRLF and trailing-newline normalized, marker line excluded) and compare with the embedded value.
- Marker verifies, file matches current templates → steady state.
- Marker verifies, file differs from current templates → a pristine
render of an older vintage: regenerated automatically, with a loud
♻️ heal notice (
--no-healopts out). - Marker fails → hand-edited: the stomp guard refuses and names the
file. Remedies, in order: move the edit to a user-owned extension
point;
--force(scoped to exactly the named files);forge disown <path> --reason "<why>"(one-way transfer to your ownership). - No marker → forge never certified those bytes: user-owned (Tier-2 scaffolds never carry markers).
Remaining committed state under .forge/ (only when non-empty):
disowned.json—forge disownrecords (path + reason + timestamp).hashes.json— render hashes for the few generated files whose format cannot carry comments (JSON outputs only).
3. The automatic migration
The first forge generate (or forge upgrade) on a project that still
has .forge/checksums.json:
- Files whose bytes match the manifest's recorded hash or any prior
render in its history → pristine: the
forge:hashmarker is stamped in place. - Disowned (and legacy forked) entries →
.forge/disowned.json. - Plain Tier-2 entries and retired paths → dropped (user-owned files need no record).
- Files matching nothing the manifest recorded (the
different-lane corruption) → quarantined. The run renders fresh
templates side-by-side; a body match proves pristineness and stamps
the file. Anything unprovable is stamped with the
forge:hash=unverified-legacysentinel and the run fails, naming each file. .forge/checksums.jsonis deleted; the.gitignorenegation is rewritten to the new state files. Commit the resulting diff.
4. Resolving unverified-legacy files
Each named file is one of two things — you decide which:
- A pristine render whose provenance the corrupted manifest lost (the
common case): run
forge generate --force.--forceis scoped to exactly the named files; they regenerate from the current templates and get real markers. - A hand-edit you want to keep: restore or back it up, then
forge disown <path> --reason "<why>"(or move the edit to the extension point the error names and then--force).
5. What changed for everyday workflows
- Regeneration no longer touches any global file: no bookkeeping commits, no manifest merge conflicts between agents.
- Merge conflicts in generated files: accept either side and run
forge generate— never hand-merge generated output. forge ci verify-generatednow verifies embedded hashes (naming hand-edited files) before the regenerate-and-diff pass.- The legacy unfork command is gone; legacy forks convert to disowns automatically during the migration.