Deep Review: 20260504-180530-pr-348

Date2026-05-04 18:05
Reporancher-sandbox/rancher-desktop-daemon
Round5
Author@jandubois
PR#348 — rdd kubectl: embed kuberlr-style version resolver
Branchembed-kuberlr
Commitsfb0811d rdd kubectl: embed kuberlr-style version resolver
ReviewersClaude Opus 4.7 (effort: xhigh), Codex GPT 5.5 (effort: xhigh), Gemini 3.1 Pro (effort: default)
VerdictMerge with fixes — S1 (Codex + Claude) is the round's most actionable: RDD_CACHE_DIR is documented in environment.md both as a behavior-changing override AND as a Path Variable whose section preamble says "setting them has no effect on RDD's behavior" — the contradiction is internal and visible to anyone who reads the file. S3 (Claude + Gemini) brings the long-standing CacheRoot panic finding back with a material change: instancePaths() now calls kuberlr.CacheRoot() unconditionally, so a user with no $HOME/%LocalAppData% sees rdd svc paths panic with a stack trace. The remaining suggestions (S2 download UX, S4 misleading test comment, S5 process-env coupling, S6 dead error scaffold) are all genuine but lower priority. Round 4's ten suggestions all landed cleanly with no regressions.
Wall-clock time23 min 8 s


Executive Summary

Round 4's ten Suggestions all landed cleanly. Round 5 surfaces no Critical or Important findings; the agents converge on six smaller items in three buckets:

  1. Documentation contradiction around RDD_CACHE_DIR (S1, two agents). environment.md line 16 documents the variable as a behavior-changing override; the Path Variables section preamble at line 39 explicitly states "setting them has no effect on RDD's behavior"; line 48 then lists RDD_CACHE_DIR in that table. Codex also notes that cmd_service.md's key table at lines 127–137 lists kubectl_cache but omits the new cache_dir key the example output (line 154) and the instancePaths() map (cmd/rdd/service_paths.go:32) include.
  2. Cache-root panic now reaches rdd svc paths (S3, Claude + Gemini). The panic in CacheRoot on os.UserCacheDir() failure is the same finding declined in Rounds 1–3, but the blast radius materially changed in this round: instancePaths() now calls kuberlr.CacheRoot() and kuberlr.CacheDir() unconditionally, so any rdd svc paths invocation (the most basic introspection command) crashes for a user with no $HOME/%LocalAppData%. Per the resolutions gate, a material code change since the decline justifies a re-raise; Suggestion-level because the trigger remains rare.
  3. Defensive cleanup in the new code — S2 (silent first-run download has no user feedback), S4 (BATS comment misnames the EXE-export mechanism), S5 (SkipResolver mutates the global process env where a package var would express the same intent without leaking into spawned children), and S6 (serverVersion declares an error return but every path returns nil, leaving Resolve's if err != nil as documented dead code).

The Windows os.Rename race surfaced again from all three agents (Codex I1, Gemini I1, Claude S3) and was dropped per the prior-round-resolutions gate (declined Rounds 1–4).

Structure: 0 Critical · 0 Important · 6 Suggestions.


Critical Issues

None.


Important Issues

None.


Suggestions

S1. environment.md describes RDD_CACHE_DIR as both behavior-changing and inert; cmd_service.md key table omits the new cache_dir row Codex Claude
| `RDD_INSTANCE` | Instance identifier. Determines which control plane and directories to use. Also settable via `rdd --instance`. | `2` |
| `RDD_DEVELOPER_MODE` | Enables developer mode: exposes hidden CLI flags, detects source tree for local builds. | unset |
| `RDD_KEEP_LOGS` | Preserves logs for post-mortem debugging. See [Log Preservation](#log-preservation) for details. | unset |
| `RDD_LOG_LEVEL` | Sets the log level (`fatal`, `error`, `warn`, `info`, `debug`, `trace`). Overridden by `--log-level` flag. When unset, defaults to `debug` in developer mode, `warn` otherwise. | unset |
| `RDD_LOG_TITLE` | When set, writes this string as the first line of each new log file. Useful for identifying log files from specific test runs or sessions. | unset |
| `RDD_CACHE_DIR` | Overrides the rdd-wide cache root. The kubectl resolver appends `kubectl/<os>-<arch>/` inside it. | `os.UserCacheDir()`/`rancher-desktop` (e.g. `~/Library/Caches/rancher-desktop` on macOS) |
| `RDD_KUBECTL_MIRROR` | Overrides the Kubernetes release mirror used to download version-matched kubectl binaries. | `https://dl.k8s.io` |

### BATS Test Variables

These variables configure the BATS test framework. They have no effect on `rdd` itself.

environment.md line 16 (Configuration Variables) correctly documents RDD_CACHE_DIR as a behavior-changing override that the kubectl resolver reads at pkg/kuberlr/cache.go:48. Line 39 then opens the Path Variables section with "setting them has no effect on RDD's behavior," and line 48 lists RDD_CACHE_DIR in that table. A reader who lands in the Path Variables section reads the opposite of what the resolver actually does.


// CacheRoot returns the rdd-wide cache root. RDD_CACHE_DIR overrides the
// OS default. Future cache consumers (k3s images, Lima templates) should
// append their own subdirectory inside this root.
func CacheRoot() string {
	if root := os.Getenv(envCacheDir); root != "" {
		return root
	}
	cache, err := os.UserCacheDir()
	if err != nil {
		panic(fmt.Errorf("could not determine user cache directory: %w", err))
```

| Variable | Description | Example (macOS, instance `2`) |
| --- | --- | --- |
| `RDD_ARGS_FILE` | Saved startup arguments | `~/Library/Application Support/rancher-desktop-2/args.json` |
| `RDD_CACHE_DIR` | rdd-wide cache root (shared across instances) | `~/Library/Caches/rancher-desktop` |
| `RDD_DIR` | Service instance directory | `~/Library/Application Support/rancher-desktop-2` |
| `RDD_CONFIG` | RDD control plane config file | `~/Library/Application Support/rancher-desktop-2/config.yaml` |
| `RDD_DOCKER_SOCKET` | Host-side Docker socket | `~/.rd2/docker.sock` |
| `RDD_KUBECTL_CACHE` | Cache directory for downloaded kubectl binaries (shared across instances) | `~/Library/Caches/rancher-desktop/kubectl/darwin-arm64` |
| `RDD_LIMA_HOME` | Lima home directory | `~/.rd2/lima` |

RDD_KUBECTL_CACHE (line 52) is correctly placed: kuberlr only reads RDD_CACHE_DIR and always derives kubectl/<os>-<arch>/, so setting RDD_KUBECTL_CACHE truly has no effect.

| `RDD_ARGS_FILE` | Saved startup arguments | `~/Library/Application Support/rancher-desktop-2/args.json` |
| `RDD_CACHE_DIR` | rdd-wide cache root (shared across instances) | `~/Library/Caches/rancher-desktop` |
| `RDD_DIR` | Service instance directory | `~/Library/Application Support/rancher-desktop-2` |
| `RDD_CONFIG` | RDD control plane config file | `~/Library/Application Support/rancher-desktop-2/config.yaml` |
| `RDD_DOCKER_SOCKET` | Host-side Docker socket | `~/.rd2/docker.sock` |
| `RDD_KUBECTL_CACHE` | Cache directory for downloaded kubectl binaries (shared across instances) | `~/Library/Caches/rancher-desktop/kubectl/darwin-arm64` |
| `RDD_LIMA_HOME` | Lima home directory | `~/.rd2/lima` |
| `RDD_LOG_DIR` | Log directory | `~/Library/Logs/rancher-desktop-2` |
| `RDD_PID_FILE` | Service PID file | `~/Library/Application Support/rancher-desktop-2/rdd.pid` |
| `RDD_SHORT_DIR` | Short directory path | `~/.rd2` |
| `RDD_TLS_DIR` | TLS certificate directory | `~/Library/Application Support/rancher-desktop-2/tls` |

The same gap carries through cmd_service.md. The example output at line 154 shows a cache_dir row, and instancePaths() at cmd/rdd/service_paths.go:32 returns cache_dir alongside kubectl_cache. The key table at lines 127–137 added kubectl_cache (line 137) but skipped cache_dir, so the table no longer matches rdd svc paths output.

		"tls_dir":       instance.TLSDir(),
		"config":        instance.Config(),
		"pid_file":      instance.PIDFile(),
		"args_file":     instance.ArgsFile(),
		"docker_socket": instance.DockerSocket(),
		"cache_dir":     kuberlr.CacheRoot(),
		"kubectl_cache": kuberlr.CacheDir(),
	}
}

const (

Fix: qualify the Path Variables preamble (or drop RDD_CACHE_DIR from the path table and reference the Configuration Variables entry), and add a cache_dir row to the cmd_service.md key table.

-`rdd svc paths --output=shell` exports these variables. They reflect
-the paths RDD uses for the current instance; setting them has no effect
-on RDD's behavior.
+`rdd svc paths --output=shell` exports these variables. Most are
+informational; `RDD_CACHE_DIR` is also honored as the cache-root
+override (see Configuration Variables above).
 | `pid_file` | PID file path |
 | `args_file` | Saved arguments file path |
+| `cache_dir` | rdd-wide cache root (shared across instances) |
 | `kubectl_cache` | Cache directory for downloaded kubectl binaries (shared across instances) |
S2. First-run rdd kubectl blocks silently for 30 s+ on slow links Claude
}

// ensureCached returns the path to a cached kubectl matching want. If no
// matching binary exists on disk, ensureCached downloads one from the
// upstream mirror and verifies its sha512 before publishing it atomically.
func ensureCached(ctx context.Context, want semver.Version) (string, error) {
	path := cachedPath(want)
	if _, err := os.Stat(path); err == nil {
		return path, nil
	} else if !errors.Is(err, os.ErrNotExist) {
		return "", err
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return "", err
	}
	if err := download(ctx, want, path); err != nil {
		return "", err
	}
	return path, nil
}

// cachedPath returns the absolute path of the cached kubectl for v.
func cachedPath(v semver.Version) string {
	name := fmt.Sprintf("kubectl-v%d.%d.%d", v.Major, v.Minor, v.Patch)
	if runtime.GOOS == "windows" {

Default log level is warn (cmd/rdd/main.go:90). The "using cached kubectl" line in kubectlAction (cmd/rdd/kubectl.go:77) and any Debug/Info logging in download stay invisible at the default level. A first-run cache miss against dl.k8s.io over a slow link blocks the terminal for 30 s or more for a ~50 MB binary; near the 5 min downloadTimeout it looks identical to a hung process. The user has no signal that the resolver is working.

	if logLevel == "" {
		// Default log level: warn for regular mode, debug for developer mode
		if developer.Mode() {
			logLevel = "debug"
		} else {
			logLevel = "warn"
		}
	}
	level, err := logrus.ParseLevel(logLevel)
	if err != nil {
		return err
		// with a nil error, so Resolve returns "" and the embedded
		// kubectl handles client-only commands.
		return fmt.Errorf("resolving kubectl version: %w", err)
	}
	if path != "" {
		logrus.WithField("path", path).Debug("using cached kubectl")
		return kuberlr.Exec(cmd.Context(), path, args)
	}
	os.Args = os.Args[1:]
	command := kubectlcmd.NewDefaultKubectlCommand()
	if err := cli.RunNoErrOutput(command); err != nil {

Fix: emit one warn-level line before the download starts, naming the version and the mirror. The line shows up at the default log level and disappears once the cache hits.

     if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
         return "", err
     }
+    logrus.Warnf("downloading kubectl v%d.%d.%d from %s", want.Major, want.Minor, want.Patch, mirrorURL())
     if err := download(ctx, want, path); err != nil {
         return "", err
     }
S3. CacheRoot panic now reaches rdd svc paths; declined in Rounds 1–3 but the call surface materially changed this round Claude Gemini
}

// CacheRoot returns the rdd-wide cache root. RDD_CACHE_DIR overrides the
// OS default. Future cache consumers (k3s images, Lima templates) should
// append their own subdirectory inside this root.
func CacheRoot() string {
	if root := os.Getenv(envCacheDir); root != "" {
		return root
	}
	cache, err := os.UserCacheDir()
	if err != nil {
		panic(fmt.Errorf("could not determine user cache directory: %w", err))
	}
	return filepath.Join(cache, "rancher-desktop")
}

The panic on os.UserCacheDir() failure is the same finding declined in Rounds 1, 2, and 3 ("declined: failure mode requires neither $HOME nor %LocalAppData% to resolve"). The blast radius materially changed this round: instancePaths() at cmd/rdd/service_paths.go:32-33 now calls kuberlr.CacheRoot() and kuberlr.CacheDir() unconditionally, so rdd svc paths (basic introspection, unrelated to the kubectl resolver) panics with a stack trace for any user whose $HOME or %LocalAppData% is unset.

		"tls_dir":       instance.TLSDir(),
		"config":        instance.Config(),
		"pid_file":      instance.PIDFile(),
		"args_file":     instance.ArgsFile(),
		"docker_socket": instance.DockerSocket(),
		"cache_dir":     kuberlr.CacheRoot(),
		"kubectl_cache": kuberlr.CacheDir(),
	}
}

const (
	outputTable = "table"

The trigger remains rare in normal desktop environments. It is reachable from stripped CI containers, restricted services, and sudo -i shells without HOME forwarding. instance.Dir() and the other path helpers do not panic on similar failures; aligning CacheRoot with the rest of the codebase keeps the surface uniform.

Fix: change the signature to (string, error), propagate from CacheDir(), and have instancePaths return (map[string]string, error). Resolve would treat a CacheRoot error like an unparseable embedded version — log and fall through to embedded.

S4. local_setup_file comment misnames the EXE mechanism — commands.bash sets but does not export Claude
}

local_setup_file() {
    GOOS=$(go env GOOS)
    GOARCH=$(go env GOARCH)
    # EXE is exported by commands.bash: ".exe" on Windows, empty
    # elsewhere. Don't shadow it locally.

    # Stage the fake kubectl into the mirror tree at the path the
    # resolver will GET when it sees serverVersion=v1.99.0.
    MIRROR_ROOT=${BATS_FILE_TMPDIR}/mirror
    MIRROR_BIN_DIR=${MIRROR_ROOT}/release/v1.99.0/bin/${GOOS}/${GOARCH}

commands.bash sets EXE="" at line 1 and EXE=".exe" at line 5 with no export. EXE is in scope for @test blocks because BATS sources commands.bash once and @test runs in the same shell. The variable inherits, but the test re-exports EXE later in the same local_setup_file (line 99, alongside the other export GOOS GOARCH EXE ...) so the rdd subprocess sees it. The comment names commands.bash as the exporter, which misleads the next reader who reorganizes BATS helpers.

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: SUSE LLC
# SPDX-FileCopyrightText: The Rancher Desktop Authors

# End-to-end test of the rdd kubectl version resolver's download / cache
# lifecycle. A fake apiserver advertises an out-of-skew version (v1.99.0),
# and a fake mirror serves a matching fake kubectl. The Go server under
# the sibling fake-kube/server directory plays both roles. The fake
# kubectl prints "fake-kubectl: <args>"; a passing test proves the
# resolver downloaded, sha-verified, and exec'd it.
current-context: fake
EOF

    CACHE_DIR=${BATS_FILE_TMPDIR}/cache

    export GOOS GOARCH EXE MIRROR_ROOT MIRROR_BIN_DIR LOG_FILE KUBECONFIG_PATH CACHE_DIR PORT GIT_VERSION_FILE VERSION_STATUS_FILE
}

local_teardown_file() {
    # Deviates from the "no teardown_file" rule. The rule's intent is to
    # preserve rdd state for post-mortem inspection; an HTTP server bound

Fix:

-    # EXE is exported by commands.bash: ".exe" on Windows, empty
-    # elsewhere. Don't shadow it locally.
+    # EXE is set in commands.bash (".exe" on Windows, empty elsewhere)
+    # and re-exported below for the rdd subprocess. Don't shadow it
+    # locally.
S5. SkipResolver mutates the global process env where a package-level bool would express the same intent without leaking into spawned children Claude
// envSkipResolver tells Resolve to short-circuit. Exec sets it on the
// kubectl child process so a downloaded kubectl that re-execs us through
// a shim cannot recurse back into version resolution.
const envSkipResolver = "RDD_KUBECTL_RESOLVED"

// SkipResolver short-circuits Resolve for the rest of this process.
// rdd ctl calls this before kubectlAction because it always targets
// the embedded apiserver, whose version matches the embedded kubectl
// by construction — the probe would always fall through anyway.
func SkipResolver() {
	os.Setenv(envSkipResolver, "1")
}

// serverProbeTimeout caps the discovery call. A reachable cluster answers
// in under 100 ms; an unreachable one would otherwise stall every kubectl
// invocation.
const serverProbeTimeout = 2 * time.Second

SkipResolver's docstring describes a same-process toggle. The implementation mutates the global process env, which leaks into every subprocess spawned after the call. The current callers (only rdd ctl invokes it before kubectlAction, which never re-spawns rdd) make this harmless today, but the function name and comment hide the side effect. A future caller who does spawn unrelated subprocesses inherits the leaked env without warning.

A package-level bool checked inside Resolve matches the documented intent and removes the coupling. The existing envSkipResolver env var still covers the recursion guard for the kubectl child, which Exec sets explicitly in exec_unix.go:22 and exec_windows.go:28.

// the current environment plus a recursion guard. The kubectl process
// inherits our PID, so the shell sees its exit status directly. ctx is
// accepted for signature parity with the Windows variant; syscall.Exec
// replaces the process so cancellation no longer applies.
func Exec(_ context.Context, path string, args []string) error {
	env := append(os.Environ(), envSkipResolver+"=1")
	if err := syscall.Exec(path, append([]string{path}, args...), env); err != nil {
		return fmt.Errorf("exec %s: %w", path, err)
	}
	return nil
}
// Ctrl+C and Ctrl+Break reach kubectl directly without explicit signal
// forwarding. The recursion guard prevents kubectl from looping through
// Resolve if it ever re-execs us.
func Exec(ctx context.Context, path string, args []string) error {
	cmd := exec.CommandContext(ctx, path, args...)
	cmd.Env = append(os.Environ(), envSkipResolver+"=1")
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	err := cmd.Run()
	var exitErr *exec.ExitError

Fix:

-func SkipResolver() {
-    os.Setenv(envSkipResolver, "1")
-}
+var skipResolver bool
+
+// SkipResolver short-circuits Resolve for the rest of this process.
+// rdd ctl calls this before kubectlAction because it always targets
+// the embedded apiserver, whose version matches the embedded kubectl
+// by construction — the probe would always fall through anyway.
+func SkipResolver() {
+    skipResolver = true
+}

In Resolve, replace the env check with if skipResolver || os.Getenv(envSkipResolver) != "".

S6. serverVersion declares an error return but every path returns nil; Resolve's if err != nil is dead Gemini
		// semver parsing. Fall through to the embedded kubectl rather
		// than break every dev invocation.
		logrus.WithError(err).Debug("kubectl resolver: embedded version not parseable; using embedded kubectl")
		return "", nil
	}
	server, ok, err := serverVersion(args)
	if err != nil {
		return "", err
	}
	if !ok {
		return "", nil
	}
	if compatible(embedded, server) {
		return "", nil

serverVersion has five return paths. Four of them return (zero, false, nil) with //nolint:nilerr annotations documenting the deliberate fall-through; the fifth returns (v, true, nil) on success. No path returns a non-nil error. The if err != nil check at Resolve line 83 cannot fire; the nilerr suppressions even document the function's deliberate "always nil" contract.

Two coherent options: drop the error return entirely (making serverVersion a (semver.Version, bool)-returning helper that matches its actual contract), or keep the scaffold and document it as deliberate forward-compatibility. The former matches what the code does; the latter matches a hypothetical future where some probe failure should abort the resolver instead of falling through.

Fix (drop the dead path):

-func serverVersion(args []string) (_ semver.Version, ok bool, _ error) {
+func serverVersion(args []string) (semver.Version, bool) {
     ...
-    return semver.Version{}, false, nil //nolint:nilerr // ...
+    return semver.Version{}, false
     ...
-    return v, true, nil
+    return v, true
 }

And in Resolve:

-    server, ok, err := serverVersion(args)
-    if err != nil {
-        return "", err
-    }
+    server, ok := serverVersion(args)
     if !ok {
         return "", nil
     }

Design Observations

Concerns

Strengths


Testing Assessment

Coverage gaps surfaced this round, ordered by user-reachable risk:

  1. os.UserCacheDir failure path (S3). The panic in CacheRoot has no test. A unit test that unsets HOME (Linux) or LocalAppData (Windows) and asserts a clean error would lock in the recommended fix.
  2. First-run download UX (S2). No test asserts that the user sees something on cache miss — neither output nor log lines. The fake-kube fixture already serves the binary; a one-line BATS assertion against ${output} after the download case would close the gap.
  3. Concurrent first-use cache miss (declined Rounds 1–4, surfaced again this round by all three agents). Acknowledged limitation.
  4. End-to-end Windows execution path (pkg/kuberlr/exec_windows.go). PR description gates the merge on Windows BATS support arriving; the path remains compile-only.
  5. Cluster reporting unparseable gitVersion Claude. serverVersion logs at warn and falls through, but no test covers it. The fake server already has --git-version-file; writing "not-a-version" and asserting fall-through to embedded kubectl would close the gap.

Verification runs this round: Codex ran make build-rdd and go test ./pkg/kuberlr ./cmd/rdd -count=1 (both passed). Claude verified Round 4 fixes against the current files. Gemini ran the discovery walk only.


Documentation Assessment


Commit Structure

Single commit fb0811d. Message describes the change and matches the diff. Clean.


Acknowledged Limitations

Declined in prior rounds


Unresolved Feedback


Agent Performance Retro

Claude

Claude carried this round's coverage: five Suggestions retained (S1 docs shared with Codex, S2 download UX, S3 panic with the blast-radius re-raise, S4 BATS comment, S5 SkipResolver env coupling). The S3 re-raise was the round's best work — Claude noticed that instancePaths() now calls kuberlr.CacheRoot() unconditionally, so the same panic that prior rounds declined as too narrow now reaches rdd svc paths. That call-surface check is exactly the discipline the resolutions gate asks for. Two findings declined or downgraded: I1 (download UX) was over-calibrated as Important — the gap is real but bounded; S5 (drive-by config.json → config.yaml in cmd_service.md) was rejected because Round 4's S3 (raised by Claude itself) explicitly asked for that rename, and re-flagging it as scope creep after it landed moves the goalposts. Claude's S3 (Windows rename race) is the fourth consecutive re-raise from Claude on a finding declined four rounds running.

Codex

Codex produced one shared Important (Windows rename race, dropped per gate) and one shared Suggestion (S1 docs contradiction). The S1 work was tight: Codex spotted both the environment.md self-contradiction and the cmd_service.md key-table omission in a single finding, where Claude reported only the environment.md half. Codex also ran make build-rdd and go test ./pkg/kuberlr ./cmd/rdd in its worktree (both passed) — that verification work is hard to parallelize across agents and Codex consistently provides it. Tight one-Important / one-Suggestion shape, zero false positives.

Gemini

Gemini's round was on-target after Round 4's hallucination. C-level findings disappeared; the two real findings (I1 Windows rename race shared with the others, S2 panic on os.UserCacheDir) were both legitimate. S1 (dead error return in serverVersion) is a fresh Suggestion that neither other agent caught — verified by reading every return path in the function and confirming all four nilerr branches plus the success path return nil. The supply-chain Cosign observation in Design Concerns is a future-scope idea worth carrying forward. No false positives this round; Gemini's coverage tightened up materially compared to Round 4.

Summary

Claude Opus 4.7Codex GPT 5.5Gemini 3.1 Pro
Duration16m 22s8m 02s
Findings5S1S2S
Tool calls35 (Bash 17, Read 17, Grep 1)74 (shell 66, stdin 8)
Design observations512
False positives000
Unique insights402
Files reviewed191919
Coverage misses000
Totals5S1S2S
Downgraded2 (I→S)00
Dropped211

Codex provided the tightest signal-per-finding ratio (one shared S1 was the round's most actionable). Claude provided the most coverage (five retained Suggestions, including the S3 blast-radius re-raise). Gemini contributed one defensive observation (S6 dead error scaffold) plus one shared finding (S3 panic).

Net Round 5 contribution after the resolutions gate: 0 Critical, 0 Important, 6 Suggestions, 2 design concerns.

#### Reconciliation


Review Process Notes

Skill improvements

Repo context updates

None this round. The existing deep-review-context.md carried Codex S1 (environment.md Path Variables preamble vs. behavior-changing override) into reach because the BATS style and CRD-default rules already train agents to read documentation tables against implementation; the same instinct surfaced the contradiction here.