v4.13.0
v4.13.0 — an upgrade no longer records history it did not earn
Full entry: CHANGELOG.md § [4.13.0]. This file records release evidence and the upgrade note.
Reported by the WorkHub Managed consumer as finding N6 (HIGH), with a three-run sandbox reproduction
across two release boundaries. This release supersedes the 2026-08-20 advisory for anyone not yet
affected; the advisory remains authoritative for installs already in the broken state.
Why this is a minor, not a patch
New behaviour and a new command: the upgrade writes a different base, /fusebase-health gains a
missing-base finding, and hooks/local/recover-missing-base.sh is new.
The defect
An upgrade that cannot classify a file preserves it — correctly. But it then recorded upstream's
current bytes as that file's base, i.e. as the history of a file it had just admitted it could not
place. One release later the classifier compared the consumer's untouched file against that base,
concluded the consumer had edited it, and reported:
consumer-only (16) — YOU changed these; upstream did not — PRESERVED
- hooks/local/upgrade.sh <- the file that would fix this
Across a release boundary the same paths became changed-by-both and the run aborted, exit 3.
Reproduced live on the shipped v4.12.0 engine. The sharpest case was hooks/local/control.sh — a
file the consumer never touched, preserved as unclassifiable, recorded with upstream's bytes, then
blamed on them and used to abort the next upgrade.
Because the repair lives in hooks/local/upgrade.sh, and that is precisely the file an affected
install never receives, v4.11.0's own fix could not reach the population it was written for.
The fix — the base records only what the run delivered
The new base omits entries for paths the run preserved as unknown-base. Apply as before; write
the base as before; leave out what was not earned.
A path omitted from the base classifies unknown-base next run — preserve and report, every run.
A path recorded with bytes the run did not earn classifies consumer-only and freezes silently.
A missing entry is visible and recoverable; a false entry is neither.
Measured, same fixture, after the fix:
unknown-base (2) — no recorded base for these — PRESERVED (cannot tell who changed them)
rc 0 · VERSION -> 4.12.0
unknown-base where it used to say "YOU changed these", and no abort.
It keys on outcome, not on cause — and that is why it reverses nothing
The obvious fix — refuse the upgrade when base synthesis failed — was already rejected in this
project's decision record: "trigger on synthesis failure — rejected: strands the forked consumer who
still received files. The refusal must key on outcome, not on cause." Refusing would have punished
the forked, no-tag and tarball consumers that decision exists to protect.
Keying on what the run actually delivered satisfies that rule. The shipped assertion protecting the
forked consumer stayed green with a zero-byte diff, verified twice.
Also in this release
/fusebase-healthdetects the missing-base state and routes tobootstrap-upgrade.sh, which stages the new engine first. For a tree that already has a base it emits a conditional pointer to the advisory, never a verdict — see the limit below.hooks/local/recover-missing-base.shrepairs a poisoned tree only where external ground truth exists (VERSION.pre-upgrade-*,docs/release-fingerprints.md, a tag-carrying source clone). Three refusal shapes, each changing nothing. It never invents a baseline.- Base provenance stamping (
prior_base,prior_version,preserved_unclassified) so a poisoned base self-identifies from the next upgrade onward. - Plugin-manifest parity is publisher-only (their N4). Consumer plugin manifests stay unmanaged — adopting them would let an upgrade overwrite a consumer's own file.
- F2 regression test only. v4.12.0's source boundary already materializes from git objects with LF forced; no normalize-on-copy was added, since a second canonicalization authority is worse than none.
Honest limits — each asserted by a test, not just written here
- This does not repair installs already poisoned. Their base already holds false entries. They are reached by
docs/ADVISORY-2026-08-20-missing-base-upgrade.mdand by the recovery tool. - The health check cannot reach an already-affected install, because the health-check script is itself among the frozen files. It protects the not-yet-exposed.
- No local signal distinguishes a poisoned tree from a healthy one. Measured: identical signatures — base present, self-hash valid,
verifyDRIFT, base matching a published manifest, VERSION advanced, same backups. The consumer's own forensic tell (base matching a published fingerprint row) is non-discriminating by design, because a successful upgrade installs the source manifest as the new base every time. The check therefore points rather than accuses. - Affected paths stay stale but visible rather than refreshed. Worse than a clean upgrade; strictly better than a silent freeze.
- The provenance stamp reaches zero already-affected installs, by construction.
Release evidence
| Item | Value |
|---|---|
| Tag verification | the v4.13.0 run is the authoritative gate; a local run is never release evidence |
| Pre-tag verification | 6c01aae green on both platforms |
| Full suite | 1160 PASS / 0 FAIL |
A pattern worth recording
Four shipped tests in this cycle were green because of a defect we were about to fix — the
forked-consumer row, the m16 remediation row, F-N5-1, and a parity assertion which, read literally,
asserted the N4 defect as correct behaviour. Each was corrected by holding the new dimension constant
rather than by weakening the assertion.
Two more were caught inside this ticket's own new work: rows that passed because the library did not
exist yet, and a check that silently disabled itself by resolving its library relative to the tree
under inspection rather than to itself.