Skip to content

Releases: fusebase-dev/fusebase-flow

v4.15.3

Choose a tag to compare

@github-actions github-actions released this 08 Sep 02:12

v4.15.3 — portable recovery writes and release completion

v4.15.3 carries the recovery, Fusebase CLI coexistence and maintainer-process work prepared in the
unpublished v4.15.0, v4.15.1 and v4.15.2 tags. The v4.15.2 tagged gate passed Linux and verified the
full skill and agent mirror corpus on Windows before legacy recovery exposed the last caller using
an absolute MSYS source path; publication remained skipped.

Delivered behavior

Area Behavior
Recovery and CLI coexistence Recovery preserves consumer and CLI-owned bytes, restores authorized Flow surfaces, retains originals for owned repairs, revalidates before apply, reports partial/refused states truthfully and converges without rewriting current targets.
Portable recovery writes Skill, agent, health-skill and command callers send repository-relative canonical sources. Skill and agent callers also send the exact authorized relative manifest for their surface. MSYS short paths and native Python long paths identify the same root without weakening source containment, manifest authorization or symlink rejection.
Phase reporting Unexpected N/A, nonzero exits, timeouts, missing phases and zero-result phases remain failures. Only the declared off-MSYS signal-reap N/A is accepted.
Release verification The tagged workflow runs the explicit 29-phase essential profile, runner-trust checks and package integrity on Linux and Windows/MSYS. Both legs and the aggregate gate must pass before publication. Full and focused diagnostics remain callable.

Upgrade guidance

For a routine Fusebase CLI refresh, use:

fusebase update --skip-skills

After a full fusebase update, restore the Flow overlay and previously authorized wiring with:

bash hooks/local/post-fusebase-update.sh

Automatic Git-hook restoration still requires prior Flow ownership proof. Use --wire-hooks only
when explicitly activating Flow hook wiring.

Evidence and limits

The v4.15.2 tagged run 34176386683 is failure evidence: Linux passed, Windows reached 633/634
essential predicates, and publication was skipped. Its full-corpus skill and agent write, no-op and
one-source-repair predicates passed. T71 then audited all four production writer callers and changed
only the remaining absolute health-skill source. The selected Windows legacy fixture passed migration,
preserved-value, second-run no-op and ambiguous-input zero-write behavior through an 8.3 temp path.
The v4.15.3 tagged two-platform workflow remains the only publication authority.

Validator receipt reuse remains disabled, so validators execute normally. Static context reduction,
fixture consolidation and no-op observations do not establish universal speed, host-token or
lint/typecheck savings. Ordinary-consumer timing, five-provider delivered-context telemetry,
real-symlink cases and Windows authority isolation/successful signing remain unverified.

v4.14.1

Choose a tag to compare

@github-actions github-actions released this 25 Aug 23:43

v4.14.1 — --wire-hooks did not wire enforcement on an occupied event

Full entry: CHANGELOG.md § [4.14.1]. This file records release evidence and the upgrade note.

CORRECTION TO v4.14.0. That release told exposed consumers to run
bash hooks/local/post-fusebase-update.sh --wire-hooks. If your .claude/settings.json already
had a PreToolUse array of your own, that command did not wire Flow's enforcement handler — it
exited 0, reported "applied 7 changes", and recorded an intent marker saying you had opted in.

Re-run it on this release, then confirm:

bash hooks/local/post-fusebase-update.sh --wire-hooks
grep -c 'hooks/handlers/pre_tool_use.py' .claude/settings.json    # must be >= 1

Published tags are immutable, so v4.14.0's note cannot be rewritten. This correction lives here.

The defect

settings-json-merge.py's add branch was a wholesale replace, correctly guarded to fire only when
there was nothing to preserve:

if event not in hooks or not hooks[event]:
    hooks[event] = [make_event_block(event)]

When the array was non-empty it fell through to two functions that both skip any block not already
naming a Flow handler — so a Flow block that was absent was never added. Stop was the only
event with an add-beside path.
Measured: all five non-Stop events had the gap.

Event Flow handler after --wire-hooks on a consumer-only array (pre-fix)
SessionStart · UserPromptSubmit · PreToolUse · PostToolUse · PreCompact 0 — not wired
Stop 1 — the only event with an add-beside path

Why this was worse than a missing feature

post-fusebase-update.sh recorded the wiring-intent marker on that exit 0. So the tree recorded
intent while enforcement was absent — and the health arm added in v4.12.0 then correctly reported
Flow PreToolUse ENFORCEMENT STRIPPED and prescribed --wire-hooks, the command that had just
produced the state
. A loop that could not converge.

The code already guarded against a near version of this: a comment at the recording site states
intent is written only on a merge that "actually succeeded… so a failed or aborted merge never leaves
an intent the health check would then report as stripped enforcement." That protection is real and it
did not cover this case, because the merge did succeed. It applied seven other changes. Success
was measured as exit 0, not as achieved the thing.

The fix

  • Add beside, never replace. When an event array is non-empty but contains no Flow block, Flow's block is appended alongside the consumer's. No drop, no reorder, no overwrite — the guarantee _widen_matchers already documented survives.
  • Intent keys on the achieved state. The marker is written only when the canonical handler is verifiably present in the merged chain, not when the merge exited 0. The predicate is ffhc_hwi_wired (0 wired / 1 absent / 2 no settings file).

Flow's block is appended as a separate block, not merged into the consumer's chain. Joining their
chain would inherit their matcher — measured as Bash|Edit|Write on a real consumer tree — which
would re-open the v4.14.0 PowerShell bypass on every such install. Multi-block-per-event is documented
host behaviour and is already the shape the "correctly wired" control case treats as valid, though
Flow's own example ships one block per event. A certain security regression avoided was preferred over
an inferred host behaviour relied upon.

Evidence

hooks/tests/test-wire-hooks-add-beside.sh — 35 rows, tag wire-hooks-beside.

Pre-fix Post-fix
Phase 20/35 PASS, 15 FAIL 35/35 PASS
Controls: key-absent, [], consumer+flow PASS ×5 events PASS
Consumer-only array FAIL ×5 PASS ×5
Convergence — health arm after a successful --wire-hooks FAILENFORCEMENT STRIPPED PASS
Unachieved wiring records no marker FAIL — marker written with handler absent PASS

The three control rows passing before the fix is the point: only the consumer-only row
discriminates. Regression: 227/227 across every phase touching these files.

Also in this release

The E2–E5 consumer-proposal close-out: three documentation additions and five backlog filings.

  • policies/approval-policy.yml now states that a VALID artifact authorizes its action repeatedly until expiry — it is not single-use. A reader could previously infer single-use from the TTL prose. The one real narrowing is recorded too: a digest-bound protected_path_edit is single-changeset, not single-use.
  • hooks/README.md gains a consumer-authored-hooks section: hooks in a group run in parallel, deny takes precedence, and a hook that cannot start or exceeds its timeout is non-blocking — the host fails open. A consumer security hook must bound its runtime well below its timeout and convert every local failure to a deny. Provenance is labelled: read from the Claude Code hooks reference by the consumer who filed it, not measured by Flow.
  • workflows/violation-recovery.md documents the FR-07 first-edit deadlock: the tool-time arm is defence-in-depth for already-staged content, the commit gate is the boundary, and the sanctioned path is stage → mint → commit → --consume.

Known residual — reported, not fixed

post-fusebase-update.sh Step 5b reports "(re)installed Flow git fallback hooks" from an exit code
plus the absence of a string, without asserting the hook file exists, is executable, or is Flow's —
the same exit-code-not-outcome shape. Lower blast radius: a transient report line, not a durable
marker a later check reads back as an accusation, and no non-convergent loop. Filed separately.

v4.14.0

Choose a tag to compare

@github-actions github-actions released this 21 Aug 17:03

v4.14.0 — the command gate could be bypassed by choosing another shell tool

Full entry: CHANGELOG.md § [4.14.0]. This file records release evidence and the upgrade note.

Security fix. Reported by the paperclip+hermes-v1 consumer as escalation E6.

Existing installs are still exposed until you re-merge. Run
bash hooks/local/post-fusebase-update.sh --wire-hooks. Upgrading the tree alone does not
update your .claude/settings.json matcher.

Read this first — what is and is not fixed

This release makes PowerShell reach the gate. It does not make the rules PowerShell-shaped.

Gated now git reset --hard, git push --force, --no-verify, npx prisma migrate deploy, fusebase deploy — anything a shell-agnostic pattern already matched
Still open Remove-Item -Recurse -Force, ri -r -fo, … | Remove-Item -Recurse
Degraded, not denied rm -Recurse -Force matches destructive_file_delete (approval) rather than the FR-06 hard deny

Writing PowerShell cmdlet patterns is rule authoring, blocked on decisions D1/D2 in
docs/backlog/command-gate-shell-evasion/. That gap was hypothetical while the tool was unreachable
by the gate. It is reachable now, and the backlog entry records that change of status.

Measured coverage lives in docs/hook-coverage.md so "PowerShell is gated" cannot be over-read.

The defect

On Windows, Claude Code exposes a PowerShell tool beside Bash. Flow's command gate never saw it:

  • hooks/handlers/pre_tool_use.pyBASH_LIKE_TOOLS = {"Bash", "Shell", "Terminal", "ExecuteCommand"}, no PowerShell.
  • .claude/settings.json.example — matcher Bash|Edit|Write|MultiEdit|NotebookEdit, so the event never reached the handler at all.

Every FR-06 hard deny, every FR-12 require_approval, and the K4 policy-error fail-closed was
bypassable by choosing the other shell tool. Protected-path and secret arms were unaffected — they
key on Edit/Write.

A second, narrower bypass was found during the fix. hooks/handlers/permission_request.py
carried its own inline set — {"bash", "shell", "terminal"} — missing PowerShell and
ExecuteCommand. Its fall-through is ask, so an FR-06 hard deny was silently degrading to an
operator prompt rather than to allow. Measured: ExecuteCommand + npx prisma migrate deploy
ask before, deny/FR-12 after.

Concrete exposure, measured on the maintainer host across ~400 transcripts: 1,104 PowerShell
tool calls
, carrying production-touching commands such as
fusebase isolated-store sql bundle --app apps/admin-area --env prod. Not hypothetical traffic.

The fix

  • One shared set. hooks/shared/command_policy.py gains COMMAND_TOOL_NAMES + is_command_tool(); both handlers import it. The two divergent copies are gone — divergence was the second bypass.
  • All matcher carriers named. .claude/settings.json.example, .codex/hooks.json.example, DEFAULT_EVENT_MATCHERS, and the upstream-discovery path that reads the example file.
  • Re-merge widens an existing matcher. New _widen_matchers() unions Flow's required tokens into an already-installed matcher instead of only writing on fresh installs. It touches only the block whose chain names a hooks/handlers/ command, so consumer tools and foreign hook blocks survive. Idempotent.
  • Three RED-first fixtures freeze the tool set: two pre_tool_use, one permission_request. Driven through a worktree at the pre-fix commit — all three allow/ask before, denied after. Real discriminators, not outcome-only assertions.

Upgrade note

Situation What happens
Fresh install Gets the widened matcher automatically
Existing install, runs --wire-hooks Matcher widened in place; foreign blocks untouched
Existing install, does not re-wire Stays exposed. Nothing forces the re-merge
Hand-written regex matcher (^(Bash|Edit)$) Never rewritten — a naive split would corrupt it. The merge prints a warning naming the matcher; edit it by hand
* or absent matcher Left alone; already matches every tool

Coverage beyond this host

PowerShell is the only command-execution carrier besides Bash observable here. No Shell,
Terminal, ExecuteCommand, pwsh or Cmd appeared in live traffic — those names remain in the
set as defence in depth.

Tool names on hosts that cannot be observed from here — Cursor (run_terminal_cmd), Cline/Roo
(execute_command), Codex (local_shell) — were deliberately not added speculatively. Membership
is now case-insensitive and lives in one line of one file, with a tripwire recording that widening is
cheap and omission is the hole.

Release evidence

Item Value
Tag verification the v4.14.0 run is the authoritative gate; a local run is never release evidence
Pre-tag verification 8d5f99e green on both platforms
Suite 166/166; handler fixtures 26/26 with subprocess parity

Known residual — not fixed here

The EOL guard's printed remedy does not work. stamp-hook-manifest.sh correctly refuses on CRLF in
an eol=lf file and advises git add --renormalize -- <files>, but when the index is already LF that
is a no-op and the guard refuses again identically. A maintainer following the advice loops. The
working repair is rewriting the worktree from the index blob. Filed separately.

v4.13.1

Choose a tag to compare

@github-actions github-actions released this 21 Aug 15:38

v4.13.1 — a test could delete your temp directory

Full entry: CHANGELOG.md § [4.13.1]. This file records release evidence and the upgrade note.

Take this release if you run the test suite on Windows. Reported by the paperclip+hermes-v1
consumer as escalation E7, after losing data to it three times.

The defect

Three tests defined their cleanup before assigning the variable it deletes:

finish() { [ -n "${TMP:-}" ] && rm -rf "$TMP"; ... }   # line 27
...
TMP="$(mktemp -d)"                                      # line 48

On Windows, TMP is a pre-set environment variable — typically
C:\Users\<you>\AppData\Local\Temp. Any early exit between those lines therefore ran rm -rf
against the operator's entire user temp directory. The [ -n ... ] guard did not help: a non-empty
value is exactly what caused it.

The trigger is a shallow .fusebase-flow-source clone — the documented adoption command
(git clone --depth 1). So it fired on the recommended path, under
FF_FULL=1 bash hooks/tests/run-tests.sh, which the adoption docs suggest for verification.

Affected: test-history-extraction.sh, test-budget-literals.sh, test-token-waste-classify.sh.

The fix

  • Variables renamed to script-namespaced names that cannot collide with an environment variable. Renaming, not initialising — initialising would leave the hazard for the next editor.
  • Every rm -rf is now conditional on a path the script itself created, not on "the variable is non-empty".
  • Tripwire comments at each site naming this incident.
  • test-po-investigate.sh was hardened too. Its tripwire states plainly that it was not destructive — no reachable cleanup existed before its assignment — but its delete was unguarded, so one inserted early-exit would have made it the fourth.

The regression rows — and why the obvious rule was wrong

Two rows now guard this, in a fast-tier phase so they run on every local default run, not only in CI.

Getting the rule right took three attempts, which is the part worth recording:

Rule Flags
ban rm -r on environment-named variables 22 files, nearly all safe
ban cleanup reachable before assignment 106 files, nearly all harmless — an unassigned $FIX aborts under set -u
the conjunction of both exactly 3 before the fix, 0 after

Only the conjunction is the defect: an unassigned $TMP does not abort — it silently resolves to a
real directory. The rule independently corroborates that test-po-investigate.sh was never
destructive, by not flagging it.

Shallow clones now skip, with a gate

A depth-1 clone genuinely lacks the pre-extraction blob — a missing premise, not a failed
contract — and the documented adoption path should not turn a consumer's first run red.

But the skip is gated on the repository actually being shallow. On a full clone, an unreachable
parent means history was rewritten, and that remains a hard FAIL. Otherwise the skip would hide the
exact regression the phase exists to catch. Verified both ways: depth-1 → 6 skip rows; the same clone
unshallowed → FAIL: extraction-commit-resolved.

Each skipped assertion emits its own row naming the reason and stating NOT VERIFIED, so it cannot
read as a pass.

Release evidence

Item Value
Tag verification the v4.13.1 run is the authoritative gate; a local run is never release evidence
Pre-tag verification 3227b2c green on both platforms
Reproduction consumer's documented trigger, against a throwaway directory: victim destroyed pre-fix, survived post-fix, all four subjects

Known residual — not fixed here

test-upgrade-source-boundary.sh also fails on a shallow clone, with a different cause: it
contains zero references to TMP/TEMP/TMPDIR and no git-history operations. Its failures were
MSYS fork exhaustion and eol_guard.py is not loadable beside managed_content_manifest.py (ModuleNotFoundError) — the latter clone-independent and apparently real. It gets its own ticket
rather than being folded into a data-loss fix.

v4.13.0

Choose a tag to compare

@github-actions github-actions released this 21 Aug 02:28

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-health detects the missing-base state and routes to bootstrap-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.sh repairs 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.md and 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, verify DRIFT, 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.

v4.12.0

Choose a tag to compare

@github-actions github-actions released this 19 Aug 10:23

Warning

ADVISORY (added 2026-08-20) — read before upgrading.
If audit/managed-content-manifest.json is missing from your install, do not run
hooks/local/upgrade.sh. It half-applies: new files arrive, the engine that calls them does not,
VERSION advances anyway, and the run writes upstream's manifest in as your base — after which
your stale files are reported as your own edits and the tree freezes.

If you have already run it from that state: do not re-run it, do not delete or
re-stamp the manifest, and do not bootstrap blindly — each recreates the same wrong base.
Preserve your *.pre-upgrade-* backups and wait for the recovery path.

Full guidance: https://github.com/fusebase-dev/fusebase-flow/blob/main/docs/ADVISORY-2026-08-20-missing-base-upgrade.md


v4.12.0 — the health check can see the enforcement layer

Full entry: CHANGELOG.md § [4.12.0]. This file records release evidence and the upgrade note.

Reported by the paperclip+hermes-v1 consumer, fifth in their escalation chain. Every claim in their
report survived an independent adversarial review before they filed it — including two of their own
companion claims that the review refuted.

Why this is a minor, not a patch

/fusebase-health gains a verdict it could not previously reach, and the manifest stampers can now
refuse to write. Both are new user-visible outcomes.

The defect

The settings arm keyed on exactly two facts: the count of expected event keys, and whether
hooks/handlers/stop.py appeared in the Stop chain. PreToolUse was never inspected. A tree where
the CLI regenerated .claude/settings.json and stripped everything was therefore indistinguishable
from a tree that never opted in, and both printed the same ✓.

The consumer observed both rows: the same line minutes after finding enforcement stripped, and again
after restoring it. Four such removals appear in their history.

Partial stripping was already caught — if stop.py survived but events were incomplete, the arm
recorded drift. The blind spot was specifically everything gone at once, which is what a wholesale
regeneration produces.

Why it matters beyond one consumer: /fusebase-health is the command an operator runs to answer
"did the update break anything?", and it answered yes-only-for-content — skills, agents, overlays —
while the runtime enforcement layer (FR-06/07/12) could be absent.

The fix

A dedicated, schema-validated intent marker — state/audit/flow-hook-wiring-intent.json — is written
only after a successful --wire-hooks, on a surface the CLI never rewrites. The arm then reads:

Marker Flow handler in the PreToolUse chain Verdict
valid, enabled present ✓ wired
valid, enabled absent DRIFT, exit 1
absent, or enabled: false either today's opt-in-default line, unchanged

Presence is not proof. The check matches the canonical handler (hooks/handlers/pre_tool_use.py)
inside the chain, not merely the "PreToolUse": key — a chain with the key and someone else's handler
is not Flow enforcement.

The drift carries its recovery, including --wire-hooks, because the default recovery explicitly
does not modify .claude/settings.json. Without that, detection would only relocate the operator's
forensics. Health remains read-only and never repairs.

Deliberate opt-out is first-class: --forget-hook-wiring records intent withdrawal, and the arm
goes quiet. A false alarm would be worse than the old silence, because it teaches operators to ignore
the alarm. Six states where the marker could lie — opt-out, settings replacement, an archived tree
carrying another checkout's marker, a malformed marker, missing settings, and a non-Flow handler — are
each handled and each tested.

Also — the stampers refuse to attest bytes that never ship

All three manifest stampers now emit a diagnostic, return non-zero, and leave the manifest
untouched
when a covered file holds CRLF under a resolved eol=lf. A warning that still writes a
knowingly non-canonical attestation is observability, not a guard.

This closes the proven CRLF subclass only. It does not settle
stamper-hashes-worktree-not-artifact — hashing committed bytes would fix artifact fidelity but
trade away local-tamper detection, and that trade stays open and undecided.

The consumer's forensics and this repo's own history converged on the same mechanism: five
occurrences here in three days, most recently a baseline file that hashed CRLF, shipped LF, and
reddened CI twice while looking clean locally, because the stamper and the verifier read the same
wrong bytes and agreed.

A guard that cannot load never fails a stamp. It degrades open and says so. The net is optional;
the manifest is not.

Also fixed

docs/release-notes/v4.7.0.md warned "use the 4.7.0 bootstrap-upgrade.sh, not the copy already
installed"
and then printed the installed path. The v4.6.1 copy has no base-synthesis step at all, so
which copy runs decides whether a base exists. The README and install docs print the same command and
were deliberately left alone — v4.11.0 made that command correct for them.

Release evidence

Item Value
Tag verification the v4.12.0 run is the authoritative gate; a local run is never release evidence
Pre-tag verification 6c4e1e5 green on both platforms, 1134/1134 on Windows
New phases hook-wiring-intent (23 rows), stamp-eol-guard (18 rows) — first full-tier run

Known residuals

  • Trees wired before this release carry no marker and stay silent until their next --wire-hooks. Marker-absence means "not known to have opted in", never "never opted in".
  • Manual cp .claude/settings.json.example wiring is a permanent false negative, unavoidable without a separate acknowledgement path.
  • Drift attribution in verify (the consumer's §3.2) is filed, not built: git-history attribution is unreliable on shallow and non-Git trees, where "no post-stamp commit" becomes false. It belongs behind an explicit flag.
  • upgrade.sh remains at its FR-25 baseline; the next change touching it must decompose it first.
  • The command gate still denies honest prose quoting a destructive pattern (K21/M8).

v4.11.0

Choose a tag to compare

@github-actions github-actions released this 16 Aug 23:34

Warning

ADVISORY (added 2026-08-20) — read before upgrading.
If audit/managed-content-manifest.json is missing from your install, do not run
hooks/local/upgrade.sh. It half-applies: new files arrive, the engine that calls them does not,
VERSION advances anyway, and the run writes upstream's manifest in as your base — after which
your stale files are reported as your own edits and the tree freezes.

If you have already run it from that state: do not re-run it, do not delete or
re-stamp the manifest, and do not bootstrap blindly — each recreates the same wrong base.
Preserve your *.pre-upgrade-* backups and wait for the recovery path.

Full guidance: https://github.com/fusebase-dev/fusebase-flow/blob/main/docs/ADVISORY-2026-08-20-missing-base-upgrade.md


v4.11.0 — an upgrade that cannot deliver no longer claims it did

Full entry: CHANGELOG.md § [4.11.0]. This file records release evidence and the upgrade note.

Reported by a consumer (finding N5, HIGH) against v4.10.1, with a mechanical reproduction.

Why this is a minor, not a patch

An upgrade that previously reported success and exited 0 can now refuse and exit 4. Automation
reading the exit code will see a new value, so it belongs in the version number rather than being
discovered.

The defect

With audit/managed-content-manifest.json absent, every pre-existing managed file classified
unknown-basekeepnot refreshed, while VERSION advanced and the run reported success. The
dry run showed no conflicts. The consumer's reproduction, same trees, one variable:

--base upstream-only (refreshed) unknown-base (silently kept) consumer-only
absent 0 26 0
present 24 0 2

24 upstream changes silently dropped, and the 2 genuinely consumer-edited files lost their
consumer-only protection into the same bucket. It was caught only by hand-grepping for three fixes
known to be missing.

The exposed population is precisely the one that most needed the release: installs whose last upgrade
ran a pre-4.7.0 engine, which never delivered a base manifest.

The fix

This completes a locked decision rather than amending one. K13a already states the invariant
unconditionally — a consumer with no base manifest does not get a tree full of unknown-base — and
ff_synthesize_base already existed. It lived only in bootstrap-upgrade.sh; the ordinary
upgrade.sh path never called it.

  • Base synthesis is extracted to hooks/local/lib/synthesize-base.sh and now runs on both engines.
  • A run-level delivery guard refuses to advance VERSION when all three hold: VERSION would change, and nothing was applied or removed, and at least one path classified unknown-base. That third clause separates "nothing to do" from "couldn't tell what to do", so a current tree and a docs-only release never trip it.
  • The refusal exits 4, distinct from both success and the changed-by-both abort, and names the recovery. The dry run surfaces the same condition.

Aborting on unknown-base was rejected, not overlooked: it is K9's Option A, already rejected as
"unusable first adoption", and it would strand the forked consumer for whom no tag can ever resolve
and who has the least recovery available.

Recovery advice now branches on why synthesis failed. Previously it could tell an operator to run
the command that had just failed. For a plain-directory source it names a source carrying history; for
an unresolvable tag it says plainly that there is no automatic recovery for this shape and gives
the fingerprint-matching route instead of inventing advice.

Also fixed

policies/module-size-baseline.txt was written with CRLF and stored as LF, so the manifest recorded a
digest of bytes that never ship. Locally the stamper and verifier both read the same wrong copy and
agreed; only a clean checkout disagrees. Fourth occurrence of that class — evidence for the open
stamper-hashes-worktree-not-artifact ticket, which is deliberately undecided because hashing
committed bytes would trade away local-tamper detection.

A test was passing because of the bug

Worth stating plainly: an assertion requiring the repair remediation to exit 0 had been green
because it reproduced the defect — it certified an upgrade that advanced VERSION while delivering
nothing. Its measurand is now the contract (the remediation runs and restores the artifact),
accepting exit 4 only when the delivery refusal was genuinely reported.

Release evidence

Item Value
Tag verification the v4.11.0 run is the authoritative gate; a local run is never release evidence
Pre-tag verification f1b4d5e green on both platforms
Scoped upgrade phases 80/80 (upgrade-classify 15, upgrade-boundary 23, preboundary-consumed 18, upgrade-repair 18, n5-delivery 6)

Known residuals

  • upgrade.sh sits at its FR-25 module-size baseline. The next change touching it must decompose it first — recorded with a named seam in docs/backlog/upgrade-sh-at-module-ceiling.
  • run_shell_phase's crash guard only fires when a phase exits non-zero with no recorded failure, so a phase that dies mid-way after recording one is indistinguishable from a completed one. Found when a phase reported 15/16 while two cases had never executed. Recorded, not fixed.
  • The command gate still denies honest prose quoting a destructive pattern (K21/M8).
  • Tag immutability is enforced by operator confirmation, not a repository ruleset.

v4.10.1

Choose a tag to compare

@github-actions github-actions released this 15 Aug 15:43

v4.10.1 — corrective release

Full entry: CHANGELOG.md § [4.10.1] and § [4.10.0]. This file records release evidence and the
upgrade note.

v4.10.0 was tagged but never published. Its release run failed, and the cause was self-inflicted:
the fingerprint-row check shipped in v4.9.2 made it impossible for any release to pass its own gate.
The tag was left in place rather than moved. v4.10.1 contains everything v4.10.0 intended to ship,
plus the fix.

What changed since v4.10.0

hooks/local/preflight.sh read a tag's target with
git for-each-ref --format='%(refname:short) %(*objectname)%(objectname)'. For an annotated tag
those two fields concatenate — the commit SHA immediately followed by the tag-object SHA — so the
field is 80 characters. The self-reference exemption compared it against a 40-character HEAD and
could never match.

Every release tag in this project is annotated, so the tag being cut always looked like a missing
fingerprint row, and preflight failed on the release runner. That deadlocks against PUBLISHING.md,
which correctly requires the row to be appended after tagging, because a tree cannot contain a row
describing its own digest.

The comparison is now a prefix match, which is exact for both shapes: %(*objectname) is empty for a
lightweight tag (so the field is the commit) and leads for an annotated one.

The fixture was the real defect. head-tag-is-exempt passed throughout because it built a
lightweight tag — a shape this project never ships. The test and the code agreed with each other
while both were wrong about the artifact. The oracle now uses git tag -a, with a second pair of rows
keeping lightweight coverage rather than trading one blind spot for the other.

Everything from v4.10.0

Identical — see docs/release-notes/v4.10.0.md and CHANGELOG.md § [4.10.0]. In short: Flow's
vendored CLI-owned assets moved from CLI 0.25.16 to 0.29.8; --app <appId> corrected to
<appPath>; 40 unrendered <%= template expressions removed and tripwired; two provider skills
adopted (20 → 22); /fusebase-health gained a CLI-version signal; a problem-catalog entry for a
deploy 502 misread as a logout; and the stampers no longer record digests of bytes that never ship.

Upgrade note

No migration, no config change beyond what v4.10.0 described.

Identify which tree you have

docs/release-fingerprints.md maps every released tree to its manifest fingerprint, including the
three unpublished tagged trees (v4.9.0, v4.9.1, v4.10.0). A released tree cannot contain its
own row — adding it changes the digest it describes — so each tree's row is published in the
following release. The v4.10.1 row will appear in the next one.

Release evidence

Item Value
Tag verification the v4.10.1 run is the authoritative gate; a local run is never release evidence
Superseded v4.10.0 — tagged, release run failed at preflight, never published, tag not moved
Pre-tag verification db369d6 green on both platforms, 1094/1094 on Windows (the CLI 0.29.8 content)

Known residuals

Unchanged from v4.10.0:

  • The command gate still denies honest prose quoting a destructive pattern (K21/M8).
  • Tag immutability is enforced by operator confirmation, not a repository ruleset.
  • The vendored CLI layer is a materialized installation snapshot, not a compatibility certification.
    A CLI newer than the bundled version is advisory, never a failure.
  • stamper-hashes-worktree-not-artifact remains open and deliberately undecided: hashing committed
    bytes would fix artifact fidelity but trade away local-tamper detection. The trade-off is the ticket.
  • Local pre-push detection of the manifest-freshness class is not shipped; CI enforces it.

v4.9.2

Choose a tag to compare

@github-actions github-actions released this 14 Aug 19:59

v4.9.2 — corrective release

Full entry: CHANGELOG.md § [4.9.2], § [4.9.1] and § [4.9.0]. This file records release evidence
and the upgrade note.

v4.9.1 was tagged but never published, for the same reason v4.9.0 was not: its release run went
red on verify-windows-msys. The failing row was a test-harness capability probe, never the shipped
hook code — Linux was green on every attempt. Both tags were left in place rather than moved, and
v4.9.2 supersedes them with the cause fixed. It contains everything v4.9.0 and v4.9.1 intended to
ship.

What changed since v4.9.1

The Windows release gate had been non-deterministic for five hosted runs. The cause is no longer a
hypothesis — it was measured, and it was the gate's own bound, not the code under test.

Change Effect on you
The Job-Object capability probe's watchdog is 15s → 46s Only reachable with the opt-in FFHC_USE_JOB_OBJECT=1. A loaded host whose PowerShell cold start needs more than 15s now gets a correct answer instead of a false "capability absent". A hung PowerShell is still killed.
The opt-in Windows fence moved to hooks/local/lib/job-fence.sh No behaviour change. run-with-timeout.sh keeps the platform-neutral bounded run and degrades to "unavailable" if the fence file is absent.

Everything else is identical to v4.9.1.

Upgrade note

Unchanged from v4.9.0 — nothing here changes runtime behaviour beyond what that release described.

Change Effect on you
A resolved python3 must prove it is ≥3.10 Previously only the python / py -3 fallbacks were checked. python3 -S -c support is NOT required.
A broken or missing git inside a repository now BLOCKS Previously it exited 0 and silently skipped the secret scan and protected-path check. Genuine outside-a-repository execution is unchanged.
Deploy reports embed an approval receipt The committed report no longer cites a gitignored path. The live approval is unchanged and still gates the deploy.

No migration, no config change.

Identify which tree you have

docs/release-fingerprints.md maps every released tree to its manifest fingerprint. Use the
hook-layer fallback if audit/managed-content-manifest.json is missing — it is absent on
repositories upgraded by a pre-4.7.0 engine.

A released tree cannot contain its own row: adding it changes the digest it describes. Each tree's
row is published in the following release. The v4.9.2 row will appear in the next one.

Release evidence

Item Value
Tag verification the v4.9.2 run is the authoritative gate; a local run is never release evidence
Pre-tag verification run 31832137608 on 3999a4averify-linux, verify-windows-msys and verify-gate all green, 1037/1037 on Windows
Superseded v4.9.1 and v4.9.0 — both tagged, both release runs failed, neither published, neither tag moved

Known residuals

Unchanged from v4.9.0 except the last, and stated rather than omitted:

  • The command gate still denies honest prose quoting a destructive pattern. Only the diagnostic
    improved; matching is unchanged and remains blocked on decisions K21/M8.
  • Tag immutability is enforced by operator confirmation, not a repository ruleset. A human gate
    cannot stop a tag moved directly on the hosting platform or by an automation that never asks.
  • Nothing here authenticates git, the interpreter, or the operator.
  • The Windows job-object probe flake is diagnosed and fixed, correcting v4.9.1's statement that
    it was an unresolved hypothesis. docs/backlog/release-gate-flaky-job-probe records the
    measurement that established the cause and the two theories it excluded. One related sizing
    question is open and recorded there: the fence's own ASSIGN-OK confirm still allows ~3s for
    PowerShell to start, so on a host slow enough to produce this ticket's cold start it falls back to
    the WS2-core path rather than fencing. That fallback is safe — the reap still applies.

v4.8.0

Choose a tag to compare

@github-actions github-actions released this 11 Aug 05:28

v4.8.0 — safety kernel

Full entry: CHANGELOG.md § [4.8.0]. This file records release evidence and the upgrade note.

Upgrade note — read before upgrading

Change Effect on you
Missing Python 3.10+ now BLOCKS a commit with staged changes Previously the FR-12 secret scan silently did not run and FR-07 printed a warning and committed. If you commit on a host without Python 3.10+, install it. --no-verify is not the answer. The hook tries python3, then python, then py -3 before refusing.
fusebase-flow-verify.yml no longer runs on push/PR .github/ ships with the template. Ordinary pushes now run no CI; run it with workflow_dispatch. Release coverage is unchanged.

What shipped

Area Item
Security FR-07 protected-path approvals now authorize by populated paths (MAJOR 11)
Security Missing-interpreter fail-closed contract (MAJOR 12)
Release integrity Publication bound to the verified SHA, not the tag name (B2)
Harness honesty Skipped discriminator is a non-pass (B4); watchdog rc 124/137 labelled, not conflated with crashes (MAJOR 7)
Recovery Predicate 32 exercises the production write path, not mirror parity (B3)
Tests One additive minimal-PATH fixture replaces two host-dependent masks; causally constrained mutation discriminator; changed-phase rows 66 → 101

Release evidence

Item Value
Branch verification run 31451240122, SHA 1227652verify-linux + verify-windows-msys + verify-gate GREEN
Tag verification the v4.8.0 tag run is the authoritative gate; a local run is never release evidence
Local full suite 929/929 PASS (developer evidence only)

Known residuals

Carried deliberately, each with a roadmap slice in docs/specs/msys-hardening-roadmap/roadmap.md:

  • pre-commit:20 exits 0 when git is unresolvable — decided (A2), not yet built.
  • A resolved python3 skips the ≥3.10 probe the fallbacks must pass.
  • preflight.sh skips Python checks silently when Python is absent.
  • Nothing here authenticates git or the interpreter. Under the locked same-principal model a caller
    controlling PATH can still supply a shim; Flow has no trust root and no signing seam.