WALKER_CONVERGENCE_PLAN

Native tool_state walker convergence plan

Scope: the definition-driven native tool_state traversals in galaxy.tool_util.{parameters,workflow_state}. Goal: kill the copy-paste / silent-drift risk between them without forcing a bad mega-abstraction.

Out of scope: strip_bookkeeping_from_workflow / _strip_bookkeeping_recursive (clean.py). That one is definition-free (blind fixed-keyset sweep incl. inside JSON blobs) and legitimately stands apart — see the sibling note at the bottom.

TL;DR

The walkers

#Function (file)RoleOutputUndeclared-key policyPath sepRepeat sizing
1walk_native_state (_walker.py)visitnew dict of leaf-callback resultsoptionally raise (check_unknown_keys) else ignore` (flat_state_path`)
2strip_undeclared_keys (parameters/visitor.py)stripmutate in place; return removed pathsdelete (unless in preserve_keys)``
3classify_stale_keys / _classify_recursive (stale_keys.py)classifylist[StaleKey] w/ 5 categories; no mutationcategorize + keep. (dotted)state-driven
4_strip_format2_recursive (clean.py)strip (format2)mutate in place; return removed pathsdelete.state-driven

Shared skeleton (all four):

  1. declared = {inp.name for inp in tool_inputs}
  2. partition keys → declared vs undeclared
  3. recurse declared containers:
    • conditional → select_which_when_native, recurse [test_param] + when.parameters
    • repeat → per instance dict, recurse repeat.parameters with _{i}
    • section → recurse section.parameters

The differences that resist merging

These are structural, not callback-shaped:

Recommendation — SHARED_PRIMITIVES + AGREEMENT_TEST

SHARED_PRIMITIVES

Keep the four walkers as separate functions (distinct output contracts stay explicit) but build them from shared, unit-tested helpers so the drift-prone sub-decisions exist once. Candidates, in order of drift risk:

  1. active_branch_params(conditional, cond_state) -> list[ToolParameterT] — the [test_param] + when.parameters assembly with the when is None fallback. Currently open-coded 4× (walker L124-127, strip L279-282, classify L174-175, format2 L451-453). Highest-value extraction: it pairs with select_which_when_native and is exactly where an inactive/active-branch bug would silently diverge between strip and classify.
  2. iter_repeat_instances(...) — encapsulate the two sizing modes behind one helper with a connection_sized: bool (or two named entry points) so the loop body is shared even though sizing differs.
  3. declared_names(tool_inputs) — trivial, but makes the partition uniform.

Each walker keeps its own tail (build dict / delete / classify), only the navigation is shared.

AGREEMENT_TEST

Add a cross-walker contract test: feed a fixture corpus (the existing fixtures/ stale/clean .ga set + a few hand-built conditional/repeat/section cases) through both classify_stale_keys and strip_undeclared_keys, and assert the set of keys classify labels non-bookkeeping equals the set strip removes (modulo the .↔| separator normalization). This directly guards the original “two traversals that must agree” concern and turns any future drift into a red test instead of a silent bug. Red-to-green: write it against today’s code; if it does not pass immediately, that itself is a finding worth surfacing before touching anything.

Why this ordering

active_branch_params is the single point where strip and classify could disagree about which keys are “in the active branch.” Extracting it + pinning the agreement makes the two walkers provably consistent on the decision that matters, without disturbing their output contracts or the runtime strip path.

Rejected / heavier alternatives

UNIFIED_CALLBACK_CORE (rejected for now)

One generic walk_native(tool_inputs, state, *, on_undeclared, on_leaf, path_sep, repeat_mode) that all four adapt to. Purest DRY, but:

STRIP_ON_CLASSIFY (rejected for now)

Re-express strip_undeclared_keys as “classify, then delete non-preserved classified keys.” Attractive — collapses #2 onto #3 and kills the exact divergence the agreement test guards. But:

DO_NOTHING (rejected)

Leave all four. The copy-paste of branch assembly across 4 sites is exactly the kind of thing that drifts. At minimum AGREEMENT_TEST is cheap insurance.

Test plan

Sibling note — strip_bookkeeping_from_workflow

Not part of this convergence. It is definition-free: strips only the fixed NATIVE_BOOKKEEPING_KEYS set, everywhere in the tree including inside JSON-string blobs, without tool models. Two callers need exactly that property — roundtrip’s standalone strip_bookkeeping mode (“remove framework noise, keep tool params”) which strip_undeclared_keys structurally cannot provide, and the GALAXY_TEST_STRIP_BOOKKEEPING_FROM_WORKFLOWS populator (no tool models). It already shares the one thing it should — the NATIVE_BOOKKEEPING_KEYS constant. Leave it; if anything, relocate it beside the walkers and document it as the definition-free counterpart.

Unresolved questions

  1. Extract iter_repeat_instances now, or defer (only #1 uses connection sizing — is one shared helper worth the connection_sized flag)?
  2. AGREEMENT_TEST separator handling — normalize .↔| in the test, or make it a nudge toward unifying the separator across walkers?
  3. Does classify’s STALE_ROOT/STALE_BRANCH richness need a strip-side equivalent, or is “strip removes it, classify explains it” the intended division?
  4. Include _strip_format2_recursive (#4) in the primitives, or leave format2 separate since it has no bookkeeping / no double-encoding / no connections?