Two DevOps engineers reviewing Linux terminal output before and after clearing Bash’s cached executable path.

Bash Still Runs the Old Command After You Install the New One

Tue, Oct 27, 2026

A CLI installation and a CLI selection are two different events. In the supplied Linux preparation run, an existing Bash process first executed refonte-hash-probe from a later directory in PATH, so Bash remembered that pathname. The run then created a second executable with the same name in an earlier directory while preserving the exact PATH text. Both files were valid and executable. The next bare invocation in that same parent Bash still printed LATE, not the newly available EARLY marker.

That is the operational boundary this playbook qualifies. The question is not whether installation succeeded, nor whether a fresh process can find the new file. The question is which pathname the already-running Bash process will select for its next invocation, and which corrective action changes the state that process actually owns. GNU Bash documents that it remembers full pathnames of executable files in a hash table and performs a full PATH search only when a command is not found there.

For this fixture, the answer is concrete: the warmed parent runs LATE until that parent's relevant command-location state is invalidated, or until the invocation deliberately bypasses bare-name lookup with the approved absolute pathname. A new Bash is a separate execution context. Its success does not repair the still-running parent. And repairing future selection does not undo anything the old executable already did.

Define which installed command is permitted to run

Start with the acceptance target, not with whatever a diagnostic happens to print. The approved result for this experiment is the executable in the owned early directory, whose inert script prints EARLY\n. The process under test is the same parent Bash that previously ran the late copy and retained its pathname. Installation becomes relevant at the moment the early/refonte-hash-probe file is created and made executable; PATH itself remains byte-for-byte unchanged.

A zero exit status is therefore necessary evidence about execution, but it is not sufficient evidence about executable identity. B2 is deliberately successful at the process-status level while selecting the wrong installed copy. This is distinct from preserving a failed Bash producer's status, where the question is which status survives a declaration boundary rather than which disk file Bash selected.

Use three decisions. ACCEPT means the specific invocation under review selected the independently declared EARLY target and its supporting controls are intact. REPAIR means evidence is complete enough to show the owning Bash would select LATE, so its lookup state or invocation form must change before that bare-name call is accepted. HOLD means executable identity remains unresolved or a required control failed; do not reinterpret missing evidence as success.

The GNU Bash 5.2 manual distributed by Debian is the reference for lookup semantics here, but the observed results belong to the supplied Bash 5.2.21 preparation run, not to Debian's packaged 5.2.15-2+b8 executable. The page identifies itself as GNU Bash 5.2, dated September 19, 2022; it identifies the Debian package as 5.2.15-2+b8 and records a source update of April 18, 2025.

Freeze the shell and two-directory fixture

The preparation baseline is intentionally narrow: GNU Bash 5.2.21(1)-release, CPython 3.12.14, Linux 6.18.55-x86_64-with-glibc2.39, and /bin/bash resolving to /usr/bin/bash. Each case starts one fresh parent Bash, so no case inherits another case's command hash table. Within that parent, PATH is exactly two owned absolute directories in this order: early:late. Relative entries, symlinks, aliases, functions named like the probe, POSIX mode, and other shells are outside the fixture.

The Bash program explicitly enables hashing with set -h, disables POSIX mode, starts with checkhash disabled, and disables alias expansion. Both fixture files are set executable with mode 0755. EARLY has SHA-256 562a29d656275210621ad80424d88ee6ce70c934c101437b76798a3acaa22e3a; LATE has SHA-256 027aad506538335411f39a8be9405c772712d83fc33a416a945c4ce3de3f79e1. They do nothing except print their distinct marker. Keeping both executable in the misleading-success cases matters: B2 must fail the selection policy without degenerating into a file-permission or missing-file test.

The Python driver supplies only LC_ALL=C to its Bash children, then the Bash program explicitly assigns and exports its two-directory PATH. That supplied environment also prevents an inherited BASH_ENV, exported shell functions, or inherited shell-option variables from becoming uncontrolled inputs. Because noninteractive Bash can consult BASH_ENV when that variable is present, controlling the inherited environment is stronger evidence than assuming --noprofile --norc disables every startup path. The Bash manual documents BASH_ENV processing for noninteractive shells separately from profile and rc-file behavior.

Keep the expected marker independent of command lookup

The fixture defines EARLY and LATE bytes before any command lookup and declares each case's expected output in CASES. That makes the marker an oracle for this synthetic selection experiment. Approval is not derived from command -v, type -P, or whichever executable happened to run.

That distinction is essential. A command-location diagnostic can explain what Bash currently remembers or what a search can enumerate; it cannot authorize arbitrary executable bytes. Likewise, the marker scripts are not authentication. They are intentionally inert, owned witnesses that let the preparation run connect a selected pathname to observable behavior under stable directory contents.

Run the complete command-cache experiment

The supplied preparation run started at 2026-10-27T07:11:03.145510+00:00 and finished at 2026-10-27T07:11:03.235459+00:00. Its result was PASS_12_CASES, and the Python process exited 0. Those timestamps identify one execution; their difference is not a benchmark. The complete Python source fingerprint is 126edcaa96a8e747a071a8c98e6761cf6806be925dbf879efa8562913ceaad82, and the generated Bash program fingerprint is 21d0d18d22f2680678576f93040a71827697078493f36f1d6e1b9cdd2654b89b.

The harness uses subprocess.run with explicit argument vectors, a supplied environment, captured streams, timeouts, text decoding, and return-code checks. Python's 3.12 subprocess documentation documents those controls, including env, capture_output, timeout, and the returned CompletedProcess. The rolling page displayed 3.12.16 when checked; the executed interpreter here was 3.12.14. The case roots use tempfile.TemporaryDirectory, whose context-manager cleanup removes the temporary tree on normal exit; the harness copies observations into the JSON report before that normal cleanup. This is useful isolation, not crash-proof forensic retention.

The recorder invokes the warmup directly in the parent-shell function and redirects stdout and stderr to owned files. Do not move that warmup into $(...) and still claim to have populated the same parent's command table. The event rows record the recorder's BASHPID; they are not an OS-level trace of the external executable. Status collection is supporting evidence here, not a pipeline lesson; retaining producer and log-sink exit evidence addresses that separate boundary.

Save the following source unchanged as bash_hash_fixture.py:

import argparse
import hashlib
import json
import os
import platform
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path


COMMAND = "refonte-hash-probe"
EARLY = b"#!/bin/sh\nprintf '%s\\n' 'EARLY'\n"
LATE = b"#!/bin/sh\nprintf '%s\\n' 'LATE'\n"
SHELL = r'''
set -u
set -h
set +o posix
shopt -u checkhash
shopt -u expand_aliases
lab_root=$1
case_name=$2
bash_bin=$3
chmod_bin=$4
rm_bin=$5
PATH="$lab_root/early:$lab_root/late"
export PATH
hash -r
record() {
    local label=$1
    shift
    "$@" > "$lab_root/$label.out" 2> "$lab_root/$label.err"
    local result=$?
    printf '%s\t%d\t%d\n' "$label" "$result" "$BASHPID" >> "$lab_root/events.tsv"
}
record version printf '%s\n' "$BASH_VERSION"
record options set -o
record checkhash_initial shopt -p checkhash
record path_initial printf '%s\n' "$PATH"
if [[ "$case_name" != cold ]]; then
    record warmup refonte-hash-probe
    record warm_hash hash -t refonte-hash-probe
    printf "#!/bin/sh\nprintf '%%s\\\\n' 'EARLY'\n" > "$lab_root/early/refonte-hash-probe"
    "$chmod_bin" 755 "$lab_root/early/refonte-hash-probe" || exit 70
fi
record path_after_install printf '%s\n' "$PATH"
record early_executable test -x "$lab_root/early/refonte-hash-probe"
record late_executable test -x "$lab_root/late/refonte-hash-probe"
record before_hash hash -t refonte-hash-probe
record command_v command -v refonte-hash-probe
record type_P type -P refonte-hash-probe
record path_candidates type -a -p refonte-hash-probe
case "$case_name" in
    hash_r) record action hash -r ;;
    hash_d) record action hash -d refonte-hash-probe ;;
    child_refresh)
        record action "$bash_bin" --noprofile --norc -c 'hash -r'
        ;;
    fresh_child)
        record child_execution "$bash_bin" --noprofile --norc -c 'refonte-hash-probe'
        ;;
    explicit_path)
        record explicit_execution "$lab_root/early/refonte-hash-probe"
        ;;
    check_existing) record action shopt -s checkhash ;;
    missing_off)
        record remove_cached "$rm_bin" -- "$lab_root/late/refonte-hash-probe"
        ;;
    missing_on)
        record action shopt -s checkhash
        record remove_cached "$rm_bin" -- "$lab_root/late/refonte-hash-probe"
        ;;
    path_assignment)
        PATH="$PATH"
        record path_after_assignment printf '%s\n' "$PATH"
        ;;
    cold|warm|command_wrapper) ;;
    *) exit 64 ;;
esac
record before_execution_hash hash -t refonte-hash-probe
if [[ "$case_name" == command_wrapper ]]; then
    record execution command refonte-hash-probe
else
    record execution refonte-hash-probe
fi
record after_hash hash -t refonte-hash-probe
record checkhash_final shopt -p checkhash
exit 0
'''

CASES = [
    ("B1", "cold", "EARLY\n", 0, "ACCEPT_COLD_LOOKUP"),
    ("B2", "warm", "LATE\n", 0, "REPAIR_LOOKUP"),
    ("B3", "command_wrapper", "LATE\n", 0, "REPAIR_LOOKUP"),
    ("B4", "hash_r", "EARLY\n", 0, "ACCEPT_RESELECTED"),
    ("B5", "hash_d", "EARLY\n", 0, "ACCEPT_RESELECTED"),
    ("B6", "child_refresh", "LATE\n", 0, "REPAIR_PARENT_LOOKUP"),
    ("B7", "fresh_child", "LATE\n", 0, "CHILD_ACCEPT_PARENT_REPAIR"),
    ("B8", "explicit_path", "LATE\n", 0, "EXPLICIT_ACCEPT_BARE_REPAIR"),
    ("B9", "check_existing", "LATE\n", 0, "REPAIR_LOOKUP"),
    ("B10", "missing_off", "", 127, "HOLD_MISSING_CACHED_PATH"),
    ("B11", "missing_on", "EARLY\n", 0, "ACCEPT_MISSING_PATH_RESEARCH"),
    ("B12", "path_assignment", "EARLY\n", 0, "ACCEPT_RESELECTED"),
]


def require(condition, message):
    if not condition:
        raise RuntimeError(message)


def digest(data):
    return hashlib.sha256(data).hexdigest()


def read_events(directory):
    events = {}
    for line in (directory / "events.tsv").read_text().splitlines():
        name, rc, pid = line.split("\t")
        require(name not in events, "Duplicate observation: " + name)
        events[name] = {
            "rc": int(rc),
            "recording_shell_pid": int(pid),
            "stdout": (directory / (name + ".out")).read_text(),
            "stderr": (directory / (name + ".err")).read_text(),
        }
    return events


def run(report, bash):
    clean_env = {"LC_ALL": "C"}
    require(bash.is_absolute() and bash.is_file(), "Supply an existing absolute Bash path")
    require(Path("/bin/sh").is_file(), "This fixture requires /bin/sh")
    chmod = shutil.which("chmod", path="/usr/bin:/bin")
    remove = shutil.which("rm", path="/usr/bin:/bin")
    require(chmod is not None and remove is not None, "Missing chmod or rm")
    version = subprocess.run(
        [str(bash), "--noprofile", "--norc", "-c", 'printf "%s" "$BASH_VERSION"'],
        env=clean_env, capture_output=True, text=True, timeout=5, check=True
    ).stdout
    require(version == "5.2.21(1)-release", "Use Bash 5.2.21(1)-release for this baseline")
    report["environment"].update(
        bash_path=str(bash), bash_resolved=str(bash.resolve()), bash_version=version,
        bash_sha256=digest(bash.read_bytes()), chmod_path=chmod, rm_path=remove,
        child_environment=clean_env, shell_script_sha256=digest(SHELL.encode()),
        early_fixture_sha256=digest(EARLY), late_fixture_sha256=digest(LATE)
    )
    with tempfile.TemporaryDirectory(prefix="refonte-bash-hash-") as temporary:
        for case_id, name, expected, expected_rc, decision in CASES:
            directory = Path(temporary) / case_id
            (directory / "early").mkdir(parents=True)
            (directory / "late").mkdir()
            late_path = directory / "late" / COMMAND
            late_path.write_bytes(LATE)
            late_path.chmod(0o755)
            if name == "cold":
                early_path = directory / "early" / COMMAND
                early_path.write_bytes(EARLY)
                early_path.chmod(0o755)
            argv = [str(bash), "--noprofile", "--norc", "-c", SHELL, "hash-lab",
                    str(directory), name, str(bash), chmod, remove]
            result = subprocess.run(argv, env=clean_env, capture_output=True,
                                    text=True, timeout=10)
            require(result.returncode == 0, case_id + ": driver shell failed: " + result.stderr)
            require(result.stdout == "" and result.stderr == "", case_id + ": uncaptured output")
            events = read_events(directory)
            row = {"case": case_id, "name": name, "decision": decision,
                   "case_root": str(directory), "driver_rc": result.returncode,
                   "expected_bare_or_command_output": expected,
                   "expected_bare_or_command_rc": expected_rc, "events": events}
            report["cases"].append(row)
            for key in ("early_executable", "late_executable"):
                require(events[key]["rc"] == 0, case_id + ": fixture not executable")
            early = str(directory / "early" / COMMAND) + "\n"
            late = str(late_path) + "\n"
            require(events["path_initial"]["stdout"] == events["path_after_install"]["stdout"],
                    case_id + ": PATH changed during installation")
            require(events["path_candidates"]["stdout"] == early + late,
                    case_id + ": wrong directory-search evidence")
            require(events["path_candidates"]["rc"] == 0, case_id + ": type -a -p failed")
            require((directory / "early" / COMMAND).read_bytes() == EARLY,
                    case_id + ": installed bytes differ")
            require(events["checkhash_initial"]["stdout"] == "shopt -u checkhash\n",
                    case_id + ": checkhash initially enabled")
            if name == "cold":
                require(events["before_hash"]["rc"] == 1, "Cold shell unexpectedly cached command")
                expected_lookup = early
            else:
                require(events["warmup"]["stdout"] == "LATE\n" and events["warmup"]["rc"] == 0,
                        case_id + ": failed warmup")
                require(events["warm_hash"]["stdout"] == late and events["warm_hash"]["rc"] == 0,
                        case_id + ": parent did not retain warmup path")
                require(events["before_hash"]["stdout"] == late,
                        case_id + ": cache changed before action")
                expected_lookup = late
            for key in ("command_v", "type_P"):
                require(events[key]["rc"] == 0 and events[key]["stdout"] == expected_lookup,
                        case_id + ": unexpected " + key)
            if name in ("hash_r", "hash_d", "path_assignment", "cold"):
                require(events["before_execution_hash"]["rc"] == 1,
                        case_id + ": expected an empty command entry")
            else:
                require(events["before_execution_hash"]["stdout"] == late,
                        case_id + ": parent cache unexpectedly changed")
            for key in ("action", "remove_cached"):
                if key in events:
                    require(events[key]["rc"] == 0, case_id + ": failed " + key)
            actual = events["execution"]
            require(actual["stdout"] == expected and actual["rc"] == expected_rc,
                    case_id + ": execution mismatch")
            if name == "missing_off":
                require(str(late_path) in actual["stderr"] and "No such file or directory" in actual["stderr"],
                        case_id + ": failure was not the missing cached executable")
            else:
                require(actual["stderr"] == "", case_id + ": unexpected execution diagnostic")
            if name in ("fresh_child", "explicit_path"):
                key = "child_execution" if name == "fresh_child" else "explicit_execution"
                require(events[key]["stdout"] == "EARLY\n" and events[key]["rc"] == 0,
                        case_id + ": alternate invocation failed")
            if name == "path_assignment":
                require(events["path_after_assignment"]["stdout"] == events["path_initial"]["stdout"],
                        case_id + ": PATH bytes changed")
            require(len({event["recording_shell_pid"] for event in events.values()}) == 1,
                    case_id + ": observations were not recorded in one parent shell")
            if expected == "EARLY\n":
                require(events["after_hash"]["stdout"] == early, case_id + ": wrong final entry")
            row["assertions"] = "PASS"
    require(len(report["cases"]) == 12, "Incomplete case matrix")
    report["status"] = "PASS_12_CASES"


if name == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--bash", default="/bin/bash")
    args = parser.parse_args()
    report = {
        "started_utc": datetime.now(timezone.utc).isoformat(), "status": "FAIL",
        "source_sha256": digest(Path(__file__).read_bytes()),
        "environment": {"python": sys.version, "platform": platform.platform()},
        "cases": []
    }
    try:
        run(report, Path(args.bash))
    except Exception as exc:
        report["error"] = {"type": type(exc).__name__, "message": str(exc)}
    report["finished_utc"] = datetime.now(timezone.utc).isoformat()
    print(json.dumps(report, indent=2))
    sys.exit(0 if report["status"] == "PASS_12_CASES" else 1)

Reproduce the declared baseline with:

python3 bash_hash_fixture.py --bash /bin/bash > bash_hash_evidence.json

Prerequisites are Linux, the declared Bash 5.2.21 build, CPython 3.12.14, /bin/sh, and chmod and rm discoverable under /usr/bin:/bin. There are no third-party Python packages. The version gate deliberately rejects a different Bash baseline. A changed source, Bash build, launcher, or environment needs its own timestamp, fingerprints, and complete case ledger rather than relabeling this evidence.

PASS_12_CASES means all twelve declared controls matched their expected outputs, including cases intentionally classified REPAIR or HOLD. It does not mean every invocation in the matrix is approved. A harness can pass because it successfully reproduced the wrong selection.

Establish the fresh-shell control

B1 is the positive control that makes B2 interpretable. Before the first probe lookup, both early/refonte-hash-probe and late/refonte-hash-probe already exist and are executable, while the parent has no retained entry for that command. hash -t refonte-hash-probe therefore returns failure for the pre-execution before_hash observation. The subsequent search and invocation print EARLY, return 0, and leave EARLY as the remembered pathname.

That behavior is consistent with Bash's documented search rule: for an external command name without a slash, Bash searches PATH, and a full search occurs when the name is not already in its hash table. Because the fixture's PATH puts the owned early directory first, the cold search should select that file.

Operationally, B1 rules out two easy but wrong explanations for B2. The later B2 result is not caused by an unexecutable EARLY file: B1 executes the same marker bytes successfully. It is not caused by a reversed directory order: type -a -p in the harness enumerates EARLY before LATE. The causal difference is the warm parent's retained command location before EARLY is installed.

Do not skip this control when adapting the pattern. Without a cold case, a later LATE result could be blamed on fixture construction instead of the command-location state being tested.

Install the earlier executable without changing PATH

B2 is the core witness. The case begins with only the LATE copy available. The parent executes bare refonte-hash-probe; it prints LATE, returns 0, and hash -t refonte-hash-probe records the full pathname under the owned late directory. Only after that warmup does the fixture write the EARLY script into the earlier directory and make it executable.

The installation changes directory contents, not the PATH variable. path_initial and path_after_install are asserted equal. Both executable checks succeed. Yet the next bare invocation in that same parent prints LATE and returns 0. That is the precise “Bash runs old executable” condition this article covers.

The explanation is a stored pathname, not stored executable contents. Bash documents a hash table of full pathnames and says a full PATH search is performed only when the command is not found there. The experiment does not replace bytes at the cached pathname, so it does not qualify what happens when an executable is modified in place.

Read cached lookup and directory enumeration as different evidence

Before any B2 corrective action, the warm cases show this split on Bash 5.2.21(1)-release:

Observation

Recorded result

What it answers here

hash -t refonte-hash-probe

.../late/refonte-hash-probe

What pathname the parent has remembered

command -v refonte-hash-probe

.../late/refonte-hash-probe

What this warmed parent reports for command resolution

type -P refonte-hash-probe

.../late/refonte-hash-probe

Hashed disk-file location in this documented Bash behavior

type -a -p refonte-hash-probe

EARLY path, then LATE path

All executable disk-file candidates found without consulting the hash table

The Bash 5.2 manual explicitly says that when a command is hashed, type -p and type -P print the hashed value, which need not be the first file in PATH; with -a, the hashed-command table is not consulted. That is why type -a -p and the retained-location diagnostics are not contradictory. They answer different questions.

Keep the exact tested spelling. Chet Ramey's April 8, 2025 Bash-5.3-rc1 maintainer announcement includes a historical changelog item concerning the combined type -a -P behavior. The supplied experiment uses lowercase type -a -p; do not silently substitute the capital-P combination or describe the 2025 release candidate as this runtime.

Test the command wrapper before trusting it

B3 takes the B2 state and changes only the invocation form to command refonte-hash-probe. The actual marker is still LATE, with return code 0. Therefore command is not an acceptable repair for this fixture's stale parent lookup.

That result fits the documented purpose of the command builtin. Bash says command name ... runs the command while suppressing normal shell-function lookup; absent its -p, -v, or -V options, it is not documented as a “clear the command hash cache” operation. Functions are deliberately excluded from this experiment, so the function-lookup property is background semantics, not another variable in the matrix.

The operational mistake is to use command as though it meant “ignore everything Bash remembers and search PATH from the beginning.” B3 shows why that assumption is unsafe on the tested build: the wrapped invocation still follows the parent's retained LATE selection. A green status only proves that LATE ran successfully.

The review rule is simple: compare the marker from the actual invocation form with the independent EARLY target. If the policy requires the newly installed earlier executable and command refonte-hash-probe returns LATE, classify that invocation REPAIR. Do not grant it acceptance because the syntax looks more explicit than a bare word.

This is also why lookup diagnostics belong beside execution evidence. A wrapper's name tells you what syntax was requested; the EARLY/LATE marker tells you which fixture executable actually produced the result.

Clear the entry in the shell that owns it

B4 and B5 perform the two direct current-shell repairs demonstrated by the fixture. In B4, the warmed parent runs hash -r. Bash documents hash -r as forgetting all remembered command locations. In B5, the same kind of parent runs hash -d refonte-hash-probe, which deletes only that command's remembered location.

After either action, before_execution_hash is absent, so the next bare command must reselect. With the stable owned directories still containing EARLY before LATE, both cases print EARLY, return 0, and finish with the EARLY pathname remembered. For this fixture, global invalidation and targeted invalidation both repair the relevant parent.

Choose the scope deliberately. hash -d refonte-hash-probe is the narrower change when only this command's location needs reselection. hash -r clears all remembered locations in that current Bash and may be appropriate when a coordinated tool rollout changes several command locations. Neither command approves the bytes that a subsequent search finds. It only changes lookup state.

Distinguish current-shell repair from a child-shell command

B6 is the ownership control: the parent is warmed on LATE, then launches a new Bash that successfully runs hash -r. The child exits successfully. The parent's before_execution_hash still names LATE, and its subsequent bare invocation still prints LATE.

The action succeeded in the wrong owner. Shell-local state belongs to the process that holds it. That same process-boundary discipline appears in shell ownership across Make recipe boundaries, but this experiment is narrower: it is about one Bash command-location table, not working directories or Make recipes.

The demonstrated child is a newly executed Bash process. This result does not qualify a parenthesized subshell or command substitution as interchangeable with that child. If a long-lived terminal, release wrapper, or orchestration shell must run the new tool by bare name, invalidate the entry in that shell. Do not point to a successful bash -c 'hash -r' child and declare the parent repaired.

Compare a new Bash process with an explicit pathname

B7 and B8 prove two useful scoped alternatives without confusing them with parent repair. In B7, the warmed parent starts a fresh Bash and asks that child to execute bare refonte-hash-probe. The child has no warmed LATE entry, so with the current two-directory filesystem it prints EARLY. Then the original parent invokes the same bare name and still prints LATE.

Accept the B7 child invocation only for what it establishes: that this newly executed Bash, under the supplied environment and current directory contents, selected EARLY. It says nothing magical happened to the parent's table. A fresh process can be a valid execution design when the workflow intentionally creates a new shell at that boundary, but it is not a mutation API for another process. This is also why the experiment should not be generalized into a claim that every CI step shares one long-lived Bash process.

B8 stays in the warmed parent but invokes the owned EARLY script by its absolute pathname. That explicit execution prints EARLY and returns 0. The parent's next bare-name invocation still prints LATE. The approved absolute pathname therefore scopes selection to that one call; it does not clear the command hash entry.

This distinction gives release automation two defensible patterns. Where bare-name lookup is part of the contract, repair the owner with targeted or global invalidation and then verify the resulting invocation. Where policy deliberately pins an approved absolute executable, invoke that pathname and validate that call directly. Do not blend the two into a vague “refresh.”

The Bash command-execution documentation distinguishes command names containing no slash, which go through shell lookup, from successful names containing a slash, which identify the named program for execution. The experiment qualifies that distinction only for its owned, stable fixture; it does not establish provenance or race-resistant execution.

Test checkhash with existing and removed commands

checkhash answers a narrower question than “is there now an earlier executable in PATH?” The Bash manual says that when checkhash is set, Bash verifies that a command found in its hash table still exists before trying to execute it; if the hashed command no longer exists, Bash performs a normal path search.

B9 enables checkhash while the cached LATE file still exists. Nothing about that condition forces a reselection merely because EARLY has appeared earlier. The parent therefore prints LATE, returns 0, and remains REPAIR for a policy that requires EARLY.

B11 changes the predicate that checkhash actually checks. The parent first enables the option, then the fixture deliberately removes only its owned cached LATE executable. On the next bare invocation, the retained pathname is missing, so the demonstrated Bash build re-searches and finds EARLY. It prints EARLY, returns 0, and records EARLY as the new entry. That is a useful control for documented missing-path behavior, not a recommended deployment technique of deleting old tools.

Preserve the deliberate missing-path failure

B10 proves the contrasting failure. With checkhash off, the fixture removes the owned LATE file after the warm diagnostics have been captured. The next bare invocation produces no marker, returns 127, and reports the deleted cached pathname with “No such file or directory.” That is the intended negative result for this case.

Treating “nonzero happened” as sufficient would weaken the experiment. The diagnostic must name the expected missing cached path, and the prior controls must show that the fixture was executable before removal. Also keep chronology straight: type -a -p was recorded before B10 and B11 removed LATE. Its EARLY-then-LATE enumeration must not be presented as a post-removal snapshot.

Reconcile an unchanged PATH assignment with the observed cache reset

B12 isolates a subtle but important distinction: changing directory contents under an unchanged PATH is not the same operation as assigning the PATH variable. The warmed parent begins with LATE remembered. After EARLY is installed, the case executes PATH="$PATH". The recorded path_after_assignment bytes are identical to path_initial, yet before_execution_hash shows that the command entry is gone on Bash 5.2.21(1)-release. The next bare invocation searches again and prints EARLY.

That is a recorded build-specific observation, not the preferred repair contract. The Bash reference establishes the command hash table and the explicit hash -r / hash -d controls; the strongest reviewable remediation is to state which entry you intend to invalidate rather than depend on an incidental environment assignment whose visible value is unchanged.

The distinction also prevents a misleading explanation of B2. EARLY's installation did not assign PATH at all. It created a file inside a directory already named by the variable. Therefore B12 does not contradict B2: B2 changes directory membership; B12 performs a shell variable assignment even though the resulting string is identical.

Do not generalize this case to other shells, other Bash builds, or arbitrary launchers without separate evidence. When a rollout procedure needs deterministic current-shell invalidation, use the documented hash operations or an explicit approved pathname, and verify the actual marker afterward.

Account for work already done by the old executable

Selection repair is prospective. If LATE already ran before you noticed the stale lookup, clearing the parent's entry changes what a future bare invocation can select; it does not erase files written, packages published, state mutated, logs emitted, or remote calls already performed by the previous executable.

The laboratory is intentionally harmless: EARLY and LATE only print markers. There is therefore no production artifact to recover. In a real release path, preserve the command-selection evidence first, then identify the work whose execution window overlaps the wrong tool identity. An owner must decide whether that work is still valid, needs rebuilding or reprocessing, or must remain held for a separate investigation.

Keep that decision separate from lookup repair. “We ran hash -d and now command -v shows the new path” is evidence about future selection in the current shell; it is not evidence that an earlier artifact was built by the intended CLI. The article's boundary stops at command selection, so artifact-specific correctness needs its own approved source, tests, and ownership decision.

Restore an approved invocation without erasing prior evidence

A disciplined recovery sequence is: preserve the parent PID or process identity available to your automation, the PATH value, retained-path diagnostics, candidate enumeration, invocation form, marker/status, and any affected-output identifiers; then select the approved path and repair or bypass lookup according to policy; finally repeat the same behavioral check for the future invocation.

If an intentional rollback changes which path is approved, record that authorization independently. Do not delete an older executable merely to provoke checkhash, and do not overwrite earlier evidence with a new successful transcript. The repaired command proves only that the new invocation met the selection rule. Recovery for prior side effects remains an explicit owner decision.

Set acceptance criteria for this command-selection boundary

The supplied preparation run produced this complete bounded matrix. All EARLY and LATE marker strings include their trailing newline. “Parent result” is the bare invocation after the controlled change unless the row says otherwise.

Case

Controlled action

Actual invocation evidence

Marker / status

Decision

B1 cold

Both files before first lookup

Parent bare name

EARLY / 0

ACCEPT_COLD_LOOKUP

B2 warm

Install EARLY after warming LATE

Parent bare name

LATE / 0

REPAIR_LOOKUP

B3 command_wrapper

Add command wrapper

Parent command name

LATE / 0

REPAIR_LOOKUP

B4 hash_r

hash -r in parent

Parent bare name

EARLY / 0

ACCEPT_RESELECTED

B5 hash_d

hash -d name in parent

Parent bare name

EARLY / 0

ACCEPT_RESELECTED

B6 child_refresh

Child runs hash -r

Subsequent parent bare name

LATE / 0

REPAIR_PARENT_LOOKUP

B7 fresh_child

Child executes probe

Child EARLY; parent then LATE

0 / 0

CHILD_ACCEPT_PARENT_REPAIR

B8 explicit_path

Parent executes EARLY absolute path

Explicit EARLY; parent then LATE

0 / 0

EXPLICIT_ACCEPT_BARE_REPAIR

B9 check_existing

Enable checkhash, LATE still exists

Parent bare name

LATE / 0

REPAIR_LOOKUP

B10 missing_off

Remove cached LATE, checkhash off

Parent bare name

no stdout / 127

HOLD_MISSING_CACHED_PATH

B11 missing_on

Enable checkhash, remove LATE

Parent bare name

EARLY / 0

ACCEPT_MISSING_PATH_RESEARCH

B12 path_assignment

PATH="$PATH" on tested build

Parent bare name

EARLY / 0

ACCEPT_RESELECTED

Acceptance requires the declared Bash baseline, intact cold and negative controls, the owning process identified correctly, diagnostics captured in the right chronology, and the selected invocation returning the independent expected marker. PASS_12_CASES alone is not approval because the harness is supposed to reproduce REPAIR rows too. An incomplete evidence report is HOLD, even if the observations collected before the interruption happen to look favorable.

Separate executable selection from byte authenticity

The fixture fingerprints source and executable bytes with SHA-256 using Python's hashlib; the 3.12 documentation describes sha256() and hexdigest() as byte-digest interfaces. That cryptographic digest is not Bash's command-location hash table. One identifies bytes; the other remembers a pathname for command lookup.

Neither is authorization by itself. A digest can show that observed bytes match a recorded byte sequence, while a pathname can show which file was selected. Neither proves who approved the tool or how it was built. That broader build-evidence boundary is illustrated separately by verifying a compiled artifact's header dependencies.

This experiment does not test concurrent replacement, malicious files, aliases, same-name functions, interpreter shims, other shells, symlinks, package-manager behavior, build provenance, or race-resistant execution. Hold any real release whose executable identity or authorization remains unresolved.

Assign tool installation and shell ownership

A reliable rollout separates responsibilities that are often collapsed into “the CLI was installed.” The tool owner defines the intended installed pathname and the approved contents for the release. The automation owner defines how long Bash processes live, whether a command is invoked by bare name or explicit path, and how a long-lived shell invalidates remembered locations after installation. The reviewer or release owner checks the evidence ledger and decides what to do with work already produced under a mismatched executable.

Retain enough evidence to reconstruct that boundary: source and generated-shell fingerprints; Bash executable path, resolved path, version, and digest; Python/runtime details; exact PATH; case ID; shell options relevant to the fixture; invocation form; hash -t, command -v, type -P, and type -a -p output; action taken; marker, stderr, and return code; and the recording-shell PID. The supplied Bash executable digest is bc5945feb8bd26203ebfafea5ce1878bb2e32cb8fb50ab7ae395cfb1e1aaaef1.

This identity discipline is adjacent to approved source, artifact, and execution identities, where a saved plan's successful execution is separated from its authorization. Here the artifact is different and the scope is much smaller, but the review principle is similar: process success is not the same question as “did the intended identity execute?”

Requalify when a material boundary changes: a different Bash build, different option spelling, different startup environment, a launcher that inserts another shell, a different process lifetime, a changed fixture source, or a policy that switches between bare-name and explicit-path execution. Do not preserve the old expected matrix merely by editing values until a changed environment turns green. An unexplained mismatch is HOLD until independently resolved.

Build stronger scripting and delivery judgment

This laboratory is specialized, but the underlying skill is broader: know which process owns mutable execution context, define the intended command identity independently, and collect enough evidence to distinguish installation from selection. Those habits fit the Linux scripting and CI/CD foundations listed in Refonte Learning's DevOps Engineering program. The current page describes a three-month format at 12 to 14 hours per week, including Linux Fundamentals and Scripting, Git/GitHub, CI/CD, containers, Terraform, cloud platforms, monitoring/logging, and a capstone; admission requires working toward a bachelor's or higher-level degree, and the page describes virtual internship opportunities as potential rather than guaranteed.

Nothing on that page verifies that this exact Bash command-hash laboratory or Bash 5.2.21 is taught, so the useful connection is the scripting-and-delivery reasoning, not a curriculum claim.

For the operational question here, end with one acceptance rule: approve the intended invocation only when the owning execution context selects the independently approved executable and the observed marker/status confirm it. If a warmed parent still retains LATE, repair that parent with the chosen current-shell invalidation, or use an explicitly approved absolute pathname for that invocation; otherwise HOLD.