The behavior gate: the modernize walk’s soundness argument
Scope (2026-06-12):
upgrade’s default is now the minimal bump (keepprofile=when the repaired tool validates at its baseline, else the minimum valid profile; codemod decisions §50, GTR097). The gate proven here bounds the opt-in--modernizewalk; read “the walk” below as that mode. The minimal default crosses a boundary only when validity strictly requires it, and any boundary a needed bump does cross still receives the same runtime-gated fixes and reporting. The walk is additionally capped by the deployment ceiling (registry decisions D23), an orthogonal installability bound outside this document’s behaviour claim: a lower cap can only shrink the set of crossed boundaries, never grow it.
Contract: the modernize walk crosses no Galaxy must_fix behaviour
boundary whose change applies to the tool, except boundaries whose change it
provably fixed on that tool. Holds by construction for novel tools; the corpus
sweep is evidence, never the argument.
Mechanism: galaxy_tool_codemod/behavior_gate.py; policy and mode selection:
the registry facade’s upgrade. Per-step structural soundness is the separate
GTR012 composition (validity-gated sequencing); this document is
the behavioural half that caps it.
The claim, decomposed
The gate computes a ceiling before the walk starts and the walk cannot exceed it. The claim reduces to five sub-claims:
The boundary catalogue is Galaxy’s own.
PROFILE_UPGRADE_CODESis vendored verbatim from Galaxy’s upgrade advisor (lib/galaxy/tool_util/upgrade/upgrade_codes.json@ b45c58a2). What counts as a profile behaviour change, and itsmust_fix/considerseverity, is Galaxy’s authoritative enumeration, not ours. Scope limitation, stated plainly: a behaviour change Galaxy itself has not catalogued is invisible to the gate; we hold the same epistemic position as Galaxy’s advisor.Applicability detection never under-reports. Each code’s per-tool detector ports the corresponding
ProfileMigration.advisepredicate (codemod decisions §25), runs on the macro-expanded view (matching Galaxy’s advisors, which parse post-expansion), and falls back to the raw tree when expansion fails, the conservative direction (over-report, never silent). Detector tightenings are one-directional: they only suppress cases provably unaffected by the change (e.g. §28’s single-simple-commandset -esuppression, and the 24.2 test-case detector’s own provably-clean checker, codemod §47), so a tightening can remove a false block but never admit a false pass. The 24.2 checker is itself parity-gated against Galaxy’s real validator with zero unsound suppressions (docs/galaxy_reimplementations.mdtouchpoint 3).Auto-fix credit is proof by execution, per tool. A crossed, applicable
must_fixcode stops being a blocker only when its mappedRuntimeGatedFix, each individually proven behaviour-preserving in its own document (GTR01421_09_fix_from_work_dir_whitespace, GTR01516_04_fix_output_format, GTR01616_04_fix_interpreter, GTR09624_2_fix_test_case_validation), is applied to a throwaway copy of this tool and the code’s detector no longer fires (code_cleared_by_autofix). There is no static “fixable codes” set: a fix’s partial coverage (GTR015’s sole-data-input subset, GTR016’s bucket A) and macro-supplied constructs the raw-tree fix cannot reach are handled exactly, because the probe re-detects on the expanded view. The live run re-verifies: the facade credits a fix inauto_fixed_codesonly when post-apply re-detection shows the code quiet.The ceiling arithmetic cannot cross a blocker. A code at version
Vis crossed by afrom -> tobump ifffrom < V <= to(upgrade_codes_crossed). The ceiling is the newest vendored profile strictly below the lowest surviving blocker’sV(behavior_ceiling), so declaring the ceiling crosses no blocked boundary, by the range inequality. Fail-closed branches: no vendored profile below the lowest blocker means no profile advance at all (blocked_below_baseline); an unplaceable baseline (an unresolved@PROFILE@token) means the crossings cannot be ranged, so the walk does not run; and the gate never lowers a declared profile.The walk respects the ceiling by construction. The ceiling threads through every declaration site:
newest_valid_profile(…, ceiling=…)skips newer profiles,UpdateProfile(ceiling=…)capsprofile=(and the inline-token rewrite), andUpgradeToLatest(ceiling=…)targets the ceiling instead of latest. The shared-macro@PROFILE@path computes each importer’s target through the same gate, and the consensus rule (every importer agrees) makes the shared bump the minimum every importer’s gate allows. Pinned bytest_behavior_gate.py, the ceiling tests intest_binding.py/test_update_profile.py/test_upgrades.py, and the facade gate tests.
What the walk does NOT claim
Applicable consider-level changes do not stop the walk by default, a
deliberate, documented policy choice (blocking on them would freeze nearly
every tool at 16.04 because Galaxy emits one such code unconditionally; see
docs/upgrade_behavior_block_stats.md). They are surfaced in the
crossed-boundary warning and an honest behavior_preserving=False, never
silently. Two opt-in flags adjust the gate, in opposite directions:
--allow-behavior-change lifts it entirely and restores the historical
structural walk, with the same reporting, and --block-consider tightens it
to stop at applicable consider-level changes too (the review-everything
mode; registry docs/decisions.md D28). The mechanism is the same
blocking_codes(levels=…) in both cases; only the policy set changes.
Evidence (not the argument)
uv run python -m scripts.corpus_check upgrade --mode modernize runs the
gated walk over every corpus tool and asserts this contract per tool
(fail-closed, gate cap, no un-fixed must_fix crossing recomputed
independently, validity preservation, byte idempotence), retaining every
violation as a regression fixture (the default --mode both also sweeps the
minimal default’s own contract). scripts.measure upgrade-behavior-blocks
reports where the walk stops, computed with these same gate functions.
Coverage guard
test_proof_documents.py asserts every RuntimeGatedFix (its GTR code and
its Galaxy upgrade_code) is named in this document, so a new auto-fix cannot
join the gate without extending this argument.