diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ed9cde1..7f80c1f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -31,7 +31,7 @@ jobs:
- run: ruff format --check .
- run: ruff check .
- run: pylint tunstrap/
- - run: vulture tunstrap/ vulture_whitelist.py
+ - run: vulture tunstrap/
- run: mypy --strict tunstrap
- name: combined unit + integration coverage gate
run: |
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f0d64af..91b679c 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -32,7 +32,7 @@ jobs:
run: pylint tunstrap/
- name: vulture
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'
- run: vulture tunstrap/ vulture_whitelist.py
+ run: vulture tunstrap/
- name: mypy --strict
run: mypy --strict tunstrap
- name: pytest (unit)
@@ -84,6 +84,126 @@ jobs:
if-no-files-found: error
retention-days: 1
+ e2e:
+ # Real kind cluster + real OpenTofu providers through a real tunnel.
+ #
+ # A separate job, not part of `integration`: the integration marker's
+ # contract is "docker compose alone", while this needs kind, tofu, kubectl,
+ # a 1.45 GB node image and an external Docker network. Folding it in would
+ # break that contract and make every integration run pay ~90s of cluster
+ # setup.
+ #
+ # It produces NO coverage artifact and is deliberately absent from the
+ # `coverage` job's combine: that gate downloads exactly two artifacts and
+ # enforces --fail-under=80, and a third, slowest, most environment-sensitive
+ # input would let a cluster flake take the coverage gate down with it.
+ #
+ # If this proves flaky, demote it with `continue-on-error: true` here rather
+ # than by weakening any assertion in tests/e2e/.
+ runs-on: ubuntu-latest
+ # Bounds a hung `kind create`, a stuck image pull, or a wedged provider
+ # download. Realistic runtime is a few minutes (cluster create + provider
+ # downloads dominate; per-init registry version-resolution is the slowest
+ # step); this has carried a stale local number twice, so it is intentionally
+ # qualitative. 20 min is a comfortable multiple of that peak and well under
+ # the 360m Actions default a true hang would otherwise ride.
+ timeout-minutes: 20
+ needs: unit
+ env:
+ # Turns every "tool missing" skip in tests/e2e/rig.py::skip_or_fail into a
+ # failure. Without it a runner that lost `kind` between the install step
+ # and the test step would report a green job with most of the tier
+ # skipped - a cluster tier that silently stops running is worse than none.
+ TUNSTRAP_E2E_REQUIRE_ALL: "1"
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+ - run: python -m pip install --upgrade pip
+ - run: pip install -e ".[dev]"
+ - name: install kind
+ run: |
+ curl -fsSL -o ./kind https://kind.sigs.k8s.io/dl/v0.30.0/kind-linux-amd64
+ chmod +x ./kind
+ sudo mv ./kind /usr/local/bin/kind
+ kind version
+ - name: install kubectl
+ # Matched to kindest/node:v1.34.0, which tests/e2e/rig.py pins.
+ run: |
+ curl -fsSL -o ./kubectl https://dl.k8s.io/release/v1.34.0/bin/linux/amd64/kubectl
+ chmod +x ./kubectl
+ sudo mv ./kubectl /usr/local/bin/kubectl
+ kubectl version --client
+ - name: install terragrunt
+ # tests/e2e/test_recipe_terragrunt.py drives real `terragrunt hcl validate`
+ # and `terragrunt render` against the HCL pinned in docs/recipe_terragrunt.md.
+ # Pinned to 1.1.1 - the version the recipe's "Measured Terragrunt facts"
+ # section is measured against; the recipe's CLI vocabulary is
+ # version-specific (hcl validate / render / run --all).
+ run: |
+ curl -fsSL -o ./terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64
+ chmod +x ./terragrunt
+ sudo mv ./terragrunt /usr/local/bin/terragrunt
+ terragrunt --version
+ - uses: opentofu/setup-opentofu@v1
+ with:
+ tofu_version: 1.12.5
+ # MANDATORY. The action's default wrapper replaces `tofu` on PATH with
+ # a script that adds GitHub annotations to real tofu's output, and this
+ # tier asserts on that output. The real-tofu path lives in
+ # test_tofu_providers.py (init/plan/apply/destroy call `tofu` directly,
+ # or a recorder that execs it); test_shim.py is unaffected because it
+ # puts a fake `tofu` first on PATH, which shadows any wrapper.
+ tofu_wrapper: false
+ - run: docker compose version
+ - name: pytest (e2e)
+ run: pytest tests/e2e -m e2e -v --junitxml=e2e-results.xml
+ - name: require the whole tier to have run
+ # Second, independent guard. TUNSTRAP_E2E_REQUIRE_ALL covers a missing
+ # tool; this covers everything else that can quietly shrink the tier -
+ # a stray @pytest.mark.skip, a collection error swallowed by -k, a
+ # deleted module. stdlib only, exact numbers, no log scraping.
+ if: always()
+ run: |
+ python - <<'PY'
+ import xml.etree.ElementTree as ET
+
+ # pytest writes ; accept both
+ # shapes so a pytest change cannot make this silently read zeros.
+ root = ET.parse("e2e-results.xml").getroot()
+ suite = root if root.tag == "testsuite" else root.find("testsuite")
+ assert suite is not None, "no element in the JUnit report"
+ counts = {k: int(suite.get(k, 0)) for k in ("tests", "skipped", "failures", "errors")}
+ print(counts)
+ assert counts["skipped"] == 0, "the e2e tier must never skip in CI"
+ assert counts["failures"] == 0 and counts["errors"] == 0
+ # Floor, not equality: adding tests must not require a CI edit, but
+ # losing them must fail. Kept as a hand-set literal deliberately: a
+ # number derived by collecting the suite would be computed from the
+ # same files pytest runs, so a deleted module shrinks the collected
+ # count and the JUnit `tests` count in lockstep and the guard would
+ # stay GREEN for exactly the deletion it exists to catch (proven
+ # tautological). Only an independent literal anchors against deletion;
+ # at 31 == the current tier it catches the loss of any one test. Bump
+ # to the new total whenever the tier grows. (Was 32; lowered to 31 by
+ # the tunstrap_tofu migration: retired 4 shell-shim-text/drift tests
+ # that the in-package proxy makes obsolete, added 3 proxy/install/gap-
+ # fix replacements, net -1. test_terragrunt_apply's pollution sub-check
+ # retired too - the proxy runs run in-process so the tunstrap-wrapper
+ # negative control no longer reaches the stream; purity is now proven
+ # by the byte-equality test. Raised back to 33 by the run_cmd follow-up:
+ # +1 test pinning the load-bearing `--terragrunt-quiet` run_cmd marker
+ # (proves the parse breaks with the marker removed) and +1 pinning the
+ # labelled shell-shim-alt alternative fence (sh -n + bypass smoke).)
+ # Raised to 34 by the inheritance follow-up: +1 test proving a unit that
+ # forgets `include "root"` is caught (apply fails at 127.0.0.1:0) AND
+ # distinguished from a forgotten-commands entry (TUNSTRAP_INPUT present
+ # vs absent) - after the rig stopped concatenating root+unit into one file.
+ assert counts["tests"] >= 34, f"expected at least 34 e2e tests, ran {counts['tests']}"
+ print("e2e tier ran in full")
+ PY
+
coverage:
runs-on: ubuntu-latest
needs: integration
diff --git a/.gitignore b/.gitignore
index db69bcc..eff43b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,4 +41,12 @@ Thumbs.db
# Integration test SSH keypair (generated per session)
tests/integration/_keys/
+# E2E rig: own SSH keypair and the extracted in-node kubeconfig.
+# Both are generated per session by tests/e2e/conftest.py. The e2e tier
+# deliberately does not share tests/integration/_keys/, which is generated by
+# a fixture pytest never loads for tests/e2e and would be absent in a clean
+# checkout.
+tests/e2e/_keys/
+tests/e2e/_kube/
+
/docs/artifacts/
diff --git a/README.md b/README.md
index 230c27e..061d8e9 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,20 @@ tunstrap stop --session-dir "$SESSION_DIR"
rm -f "$KUBECONFIG_FILE"
```
+### `start` JSON output and credential-bearing entries
+
+With the default `daemon.materialize: false`, `start --output json` carries
+`content_b64` on stdout because it is the only delivery channel for the patched
+kubeconfig or fetched file. These values can include operator-chosen secrets.
+Treat this mode's stdout as secret material: do not send it to CI logs or durable
+shell captures.
+
+Set `daemon.materialize: true` (or pass `--materialize` in flag mode) when the
+session directory is an acceptable credential location. Each patched kubeconfig
+and fetched file is then written mode `0600` under `tunnel-data/`. Materialized
+kube targets contain only `{path, context, endpoint}`; materialized fetched
+files contain only `{path, size, sha256}`. Neither form carries inline content.
+
The `daemon.auto_stop_idle_seconds: 600` setting makes the daemon shut
itself down after 10 minutes with no client connections. Useful for
ephemeral CI runs that may abort before reaching `tunstrap stop`.
@@ -116,6 +130,10 @@ sed -i "s|server: https://127.0.0.1:6443|server: https://127.0.0.1:${PORT}|" \
tunstrap stop --session-dir "$SESSION_DIR"
```
+With `daemon.materialize: true` (or `--materialize` in flag mode), read the
+fetched file from `fetch_files.kubeconfig.path` instead; see [`start` JSON
+output and credential-bearing entries](#start-json-output-and-credential-bearing-entries).
+
## CLI run modes (flag input, `--output env`, `run`)
Besides the JSON-on-stdin interface above, a single remote host can be driven
@@ -149,34 +167,72 @@ tunstrap start root@edge1.example.net \
### `--output env` (consume via `eval`)
`start` defaults to `--output json`. With `--output env` it instead prints
-POSIX `export` lines (and force-materializes kube files), ready for `eval`:
+POSIX `export` lines and force-materializes both patched kubeconfigs and
+fetched files under `tunnel-data/` (mode 0600; see [On-disk
+materialization](#on-disk-materialization)), ready for `eval`:
```bash
eval "$(tunstrap start root@edge1 --ssh-key ~/.ssh/id_ed25519 \
--target api=127.0.0.1:6443 --kube k3s=/etc/rancher/k3s/k3s.yaml --output env)"
-curl "http://$TUNSTRAP_API_ENDPOINT/healthz"
kubectl get nodes # KUBECONFIG is exported automatically
tunstrap stop --session-dir "$TUNSTRAP_SESSION_DIR"
```
-Variables emitted (no node segment; names upper-cased, non-alphanumerics → `_`):
+Variables emitted:
| Variable | Meaning |
|---|---|
| `TUNSTRAP_SESSION_DIR` | Session dir — pass to `stop --session-dir`. |
| `TUNSTRAP_PID` | Daemon PID. |
-| `TUNSTRAP__PORT` | Local forwarded port for `--target NAME=...`. |
-| `TUNSTRAP__ENDPOINT` | `127.0.0.1:` for a target; full URL for a kube target. |
-| `TUNSTRAP__KUBECONFIG` | Materialized kubeconfig path for `--kube NAME=...`. |
-| `KUBECONFIG` | Colon-joined paths of all kube targets. |
+| `TUNSTRAP_OUTPUT_FILE` | Absolute path to the materialized unified output JSON. |
+| `KUBECONFIG` | Colon-joined materialized paths of all kube targets; emitted when at least one kube file exists. |
+| `KUBE_CONFIG_PATH` | Same value as `KUBECONFIG`; emitted with exactly one kube file. It takes precedence over `KUBE_CONFIG_PATHS` in the OpenTofu providers. |
+| `KUBE_CONFIG_PATHS` | Same value as `KUBECONFIG`; emitted with two or more kube files. It is not emitted with `KUBE_CONFIG_PATH`. |
### `run` (foreground wrapper with guaranteed teardown)
-`run` opens the tunnel, injects the same `TUNSTRAP_*` / `KUBECONFIG`
-environment into a child command, waits for it, and then **always** tears the
-tunnel down (even if the child crashes or fails to launch):
+`run` opens the tunnel and injects the same session scalars and kube channel
+(`KUBECONFIG` / `KUBE_CONFIG_PATH` / `KUBE_CONFIG_PATHS`, as applicable) into a
+child command. It always removes inherited `KUBECONFIG`,
+`KUBE_CONFIG_PATH`, and `KUBE_CONFIG_PATHS` before starting the child, even
+when the input has no kube targets. A direct `tunstrap run host -- kubectl ...`
+therefore does not retain an unrelated operator kube configuration.
+
+`run` waits for the child and then **always attempts teardown** (even if the
+child crashes or fails to launch). Teardown normally
+stops the daemon and removes the session data. When it cannot confirm the stop
+— the stop reports a failure, raises, or the recorded identity is unreadable —
+it **keeps** the session data instead and prints the `tunstrap stop
+--session-dir …` command that finishes the job, rather than destroying the only
+handle on a daemon that may still be running. Either way the child's exit code
+is never changed by teardown:
+
+`stop` follows the same rule, so that recovery command is safe to run and safe
+to repeat: it removes `tunnel-data` only after a confirmed stop (or a `not
+found`, which means no daemon is recorded), and otherwise leaves the session
+data in place, adds `"preserved": true` to its JSON line and explains itself on
+stderr. That includes the cases where it cannot read
+`tunnel-data/daemon.pid` at all — missing, unreadable or malformed: `stop`
+deletes nothing on any of them, so all three report `"preserved": true` too.
+`stop` exits 0 only for the three outcomes that clean `tunnel-data`: stopped,
+forced, and `not found`. It exits 1 for every preserved outcome: identity
+mismatch, identity check unavailable, still alive, identity changed during
+grace, and the three identity-read failures (missing, unreadable, or malformed
+`daemon.pid`). Repeating an unresolved recovery command keeps returning 1 until
+the session is resolved by hand; the loop's behaviour is unchanged, only its
+status, and repetition could never resolve a preserved session on its own.
+`"preserved"` is therefore present on exactly the outcomes that kept data, and
+absent on exactly those that cleaned it; the three cleaning shapes
+(`{"stopped": true}`, `{"stopped": true, "forced": true}` and
+`{"stopped": false, "reason": "not found"}`) are unchanged to the byte, so a
+strict-schema consumer of those is unaffected. Two outcomes it cannot resolve on its own are `identity mismatch` and
+`identity check unavailable` — the recorded pid can no longer be verified as
+ours, so `stop` refuses to signal it rather than risk killing an unrelated
+process. Re-running will keep reporting the same thing; that is the case the
+preserved `tunnel-data/daemon.pid` exists for, and it has to be resolved by
+hand:
```bash
tunstrap run root@edge1 \
@@ -188,10 +244,111 @@ tunstrap run root@edge1 \
Everything after `--` is the child command and its arguments. `SIGINT` /
`SIGTERM` are forwarded to the child.
+**`--` is mandatory** whenever the child command or any of its arguments
+begins with `-`. Without it Click parses those tokens as tunstrap's own
+options: `tunstrap run --input-env X tofu -version` fails with
+`No such option: '-v'`. Everything after `--` reaches the child verbatim,
+including flags spelled like tunstrap's own.
+
+#### Env input: `--input-env VAR`
+
+`run` can take the complete `InputSchema` as JSON from an environment
+variable instead of from a connection argument and flags. This is the only
+out-of-band input channel a foreground wrapper has: `run`'s child inherits
+stdin, so stdin is not available to `run` as a control channel.
+
+```bash
+TUNSTRAP_INPUT="$(cat payload.json)" \
+ tunstrap run --input-env TUNSTRAP_INPUT -- helm list
+```
+
+In this mode there is no `USER@HOST[:PORT]` argument — every token after `--`
+is the child command. The following are usage errors (exit `64`), because the
+payload's own `nodes` and `daemon` blocks are complete and authoritative and
+there must be exactly one place to look:
+
+- any connection flag: `--ssh-key`, `--ssh-key-passphrase`,
+ `--ssh-password-stdin`, `--target`, `--kube`, `--fetch`;
+- any daemon flag: `--auto-stop-idle-seconds` (use `daemon.auto_stop_idle_seconds`),
+ `--grace-seconds` (use `daemon.shutdown_grace_seconds`),
+ `--log-file` (use `daemon.log_file`), `--materialize` (redundant, see below).
+
+Payload problems are exit `1` with a `SchemaValidationError` envelope on
+**stderr**: the variable unset, empty or whitespace-only; its content not
+JSON; or the JSON not satisfying `InputSchema`. All of these are decided
+before any daemon is started.
+
+**`run` always materializes.** It overrides `daemon.materialize` to `true`
+even when the payload sets it to `false`. `KUBECONFIG` injection needs a real
+file on disk, and an unmaterialized kube target would give `--output-var`
+consumers `"path": null`. This is the one place `run` modifies the supplied
+schema.
+
+#### Structured output: `--output-var NAME`
+
+`--output-var NAME` puts a JSON-encoded, node-keyed unified structure under
+`NAME`, alongside the three session scalars and the kube channel when kube
+files exist. Its top-level keys are `session` and `nodes`; each node contains
+`ports`, `kube`, and `fetch_files`.
+
+Each `nodes..ports.` value is the string
+`"127.0.0.1:"`, not an integer. The Terragrunt/OpenTofu recipe
+extracts the port with `split(":", ...)[1]`; see
+[`docs/recipe_terragrunt.md`](docs/recipe_terragrunt.md).
+
+`run` always materializes. The unified structure carries kube references as
+`{path, context, endpoint}` and fetched-file references as `{path, size,
+sha256}` (or `{error}` for an optional fetch failure). It does not carry file
+content or kube credentials. The documented consumer binds `NAME` to a
+Terraform variable, while `path` names the on-disk file a consumer can read.
+`tunstrap start --output json` likewise projects materialized kube and
+fetched-file entries; only an unmaterialized `start` envelope carries inline
+content on stdout.
+
+```bash
+tunstrap run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap \
+ -- tofu plan
+```
+
+The scalar environment carries session bookkeeping and kube file locations; it
+does not describe ports, warnings, or node-qualified metadata. `--output-var`
+is the node-keyed structured channel for that metadata.
+
+- The variable named by `--input-env` is **removed** from the child's
+ environment. It holds the `InputSchema`, whose `ssh_pkey` is an SSH private
+ key, and the child (`tofu`) would otherwise pass it to every provider
+ plugin, `external` data source and `local-exec` provisioner.
+
+- `NAME` must match `[A-Za-z_][A-Za-z0-9_]*`, else exit `64`.
+- `NAME` may not collide with a variable `run` itself injects or scrubs, else
+ exit `64`.
+ Collision with an unrelated inherited variable is a documented overwrite.
+- **Any node count:** the three session scalars are injected, as is the
+ cardinality-appropriate kube channel when kube files exist; `NAME` is added
+ if given. Ports remain available in the unified JSON or its materialized file,
+ not as target-scoped environment variables.
+
+> This is the flag the Terragrunt/OpenTofu recipe builds on — it puts the
+> connection envelope (kube credentials projected out) into `TF_VAR_tunstrap`
+> for a module to decode. See
+> [`docs/recipe_terragrunt.md`](docs/recipe_terragrunt.md).
+
+#### `run` never writes to stdout
+
+After the child starts, `run` writes nothing to file descriptor 1 — stdout
+belongs exclusively to the child. Every tunstrap diagnostic, including
+teardown failures, goes to stderr, and a teardown failure never changes the
+exit code. (`tunstrap stop` is unaffected: its JSON line on stdout is still
+its documented contract.)
+
**Exit codes (`run`):** the child's exit code wins on success. Before the
-child runs, `run` may exit with `2` (required tunnel failure), `3` (a live
-session already holds the requested `--session-dir`), or `4` (daemon error);
-`127` if the child binary cannot be launched.
+child runs, `run` may exit with `64` (usage error, including every row of the
+`--input-env` conflict matrix and an invalid or colliding `--output-var`),
+`1` (bad `--input-env` payload),
+`2` (required tunnel failure), `3` (a live session already holds the
+requested `--session-dir`), or `4` (daemon error). `127` if the child binary
+cannot be launched, and `4` for any unexpected failure after the tunnel came
+up — in which case the daemon has already been torn down.
## Input reference (`InputSchema`)
@@ -202,6 +359,7 @@ session already holds the requested `--session-dir`), or `4` (daemon error);
| `nodes` | `dict[str, NodeInput]` | required | One entry per remote host |
| `daemon.log_file` | `str \| null` | `null` | If set, daemon's stdout/stderr go here. Never contains fetched content. |
| `daemon.shutdown_grace_seconds` | `int` | `10` | SIGTERM grace period before SIGKILL |
+| `daemon.startup_timeout_seconds` | `int` | `300` | Bounds the parent's wait for the worker's startup IPC frame. On expiry the parent terminates the worker within `shutdown_grace_seconds`. Must exceed a node's worst-case startup: `fetch_files` and `kube_targets` are fetched serially, each bounded by `ssh_options.connect_timeout`. |
| `daemon.auto_stop_idle_seconds` | `int \| null` | `null` | Seconds of idle (no active forward connections) before the daemon SIGTERMs itself. `null` disables. |
| `daemon.materialize` | `bool` | `false` | Write patched kubeconfig files to `/tunnel-data/` (mode 0600). See [On-disk materialization](#on-disk-materialization). |
@@ -217,7 +375,7 @@ session already holds the requested `--session-dir`), or `4` (daemon error);
| `ssh_pkey_passphrase` | `str \| null` | `null` | Optional passphrase for `ssh_pkey` |
| `remote_targets` | `dict[str, str] \| null` | `null` | Up to 16 entries; each value is `"host:port"`. Host is resolved on the SSH server side, enabling bastion-style cross-host forwards. |
| `ssh_options.compression` | `bool` | `false` | Enable SSH compression |
-| `ssh_options.connect_timeout` | `int` | `60` | Seconds |
+| `ssh_options.connect_timeout` | `int` | `60` | Seconds for connection establishment and each SFTP file fetch. |
| `required` | `bool` | `true` | If false, this node may fail without aborting `start` |
| `fetch_files` | `dict[str, FileSpec] \| null` | `null` | Files to read at start (max 16) |
| `kube_targets` | `dict[str, KubeTarget] \| null` | `null` | Kubernetes clusters to access via the SSH tunnel (max 16). See [Kube mode](#kube-mode-kube_targets). |
@@ -249,12 +407,23 @@ each entry the tool:
5. Probes the apiserver's TLS certificate SAN to choose a `tls-server-name`.
6. Rewrites `server:` to `https://127.0.0.1:` and injects
`tls-server-name`. Other clusters in the file are byte-stable.
-7. Returns the patched kubeconfig plus already-extracted fields
+7. Renames the `current-context`'s context, cluster, and user to the
+ deterministic `tunstrap--` -- the consumer-facing literal
+ documented in `docs/recipe_terragrunt.md`. Non-selected contexts keep
+ their own names; their `cluster`/`user` references are rewritten when they
+ point at the renamed cluster/user. A fetched kubeconfig that already
+ contains `tunstrap--` in any `clusters`, `users`, or
+ `contexts` entry is rejected: the target fails (subject to `required`)
+ rather than the name being uniquified, because the deterministic name is a
+ contract consumers rely on.
+8. Returns the patched kubeconfig plus already-extracted fields
(`endpoint`, `certificate_authority_data`, `client_certificate_data`,
`client_key_data`, `tls_server_name`).
-**One cluster per target.** The tool takes the `current-context` and ignores
-all other contexts/clusters in the file. To access two clusters, use two
+**One cluster per target.** Only the `current-context` triple (its context,
+cluster, and user) is selected and renamed; other contexts keep their own
+names, but their `cluster`/`user` references are rewritten when they point at
+the renamed cluster/user. To access two clusters, use two
`kube_targets` entries. If the kubeconfig contains more than one context, a
`warnings[]` entry names the ignored contexts.
@@ -291,17 +460,22 @@ explicit `tls_server_name` is set:
| Field | Description |
|---|---|
-| `cluster_name` | Cluster name from the kubeconfig |
-| `context_name` | `current-context` value |
-| `local_port` | OS-assigned local forwarded port |
+| `cluster_name` | Deterministic renamed cluster identity `tunstrap--` (unmaterialized only) |
+| `context_name` | Deterministic renamed context identity `tunstrap--`, i.e. the patched file's `current-context` (unmaterialized only) |
+| `local_port` | OS-assigned local forwarded port (unmaterialized only) |
| `endpoint` | `https://127.0.0.1:` |
-| `tls_server_name` | Chosen TLS server name, or `null` on insecure fallback |
-| `certificate_authority_data` | Base64 CA cert, or `""` on insecure fallback |
-| `client_certificate_data` | Base64 client cert |
-| `client_key_data` | Base64 client private key |
-| `content_b64` | Full patched kubeconfig (always present) |
+| `tls_server_name` | Chosen TLS server name, or `null` on insecure fallback (unmaterialized only) |
+| `certificate_authority_data` | Base64 CA cert, or `""` on insecure fallback (unmaterialized only) |
+| `client_certificate_data` | Base64 client cert (unmaterialized only) |
+| `client_key_data` | Base64 client private key (unmaterialized only) |
+| `content_b64` | Full patched kubeconfig (unmaterialized only) |
| `path` | Absolute path to the materialized file, or `null` if `daemon.materialize=false` |
+Materialized `start --output json` targets use the projected form `{path,
+context, endpoint}`. Only the key is renamed from the raw envelope's
+`context_name` to `context`; its value is identical in both shapes and is
+always `tunstrap--`.
+
## Output reference
**Success (`OutputSchema`)**
@@ -322,8 +496,8 @@ explicit `tls_server_name` is set:
},
"kube_targets": {
"k3s": {
- "cluster_name": "default",
- "context_name": "default",
+ "cluster_name": "tunstrap-edge1-k3s",
+ "context_name": "tunstrap-edge1-k3s",
"local_port": 40124,
"endpoint": "https://127.0.0.1:40124",
"tls_server_name": "edge1.example.net",
@@ -337,7 +511,6 @@ explicit `tls_server_name` is set:
}
},
"pid": 12345,
- "token": "",
"session_dir": "/tmp/tunstrap-session-abc123",
"started_at": "2026-05-30T10:00:00Z",
"warnings": []
@@ -346,6 +519,40 @@ explicit `tls_server_name` is set:
`session_dir` is **always** present. Pass it to `stop --session-dir`.
+With `daemon.materialize: true`, `start --output json` retains the
+`OutputSchema` envelope but projects every materialized content-bearing entry
+to a reference. For example:
+
+```jsonc
+{
+ "connections": {
+ "edge1": {
+ "ports": {"kubeapi": 40123},
+ "fetch_files": {
+ "kubeconfig": {
+ "path": "/tmp/tunstrap-session-abc123/tunnel-data/fetch-edge1-kubeconfig",
+ "size": 2918,
+ "sha256": "d2a0bf3c..."
+ }
+ },
+ "kube_targets": {
+ "k3s": {
+ "path": "/tmp/tunstrap-session-abc123/tunnel-data/kube-edge1-k3s",
+ "context": "tunstrap-edge1-k3s",
+ "endpoint": "https://127.0.0.1:40124"
+ }
+ }
+ }
+ }
+}
+```
+
+The materialized kube projection renames only raw `context_name`'s key to
+`context`; its value is identical in both shapes and is always
+`tunstrap--`.
+`--output-var` uses the same projected references under its node-keyed
+`{session, nodes}` structure, rather than this `OutputSchema` envelope.
+
**Failure (`ErrorOutput`)**
```json
@@ -382,11 +589,12 @@ failure.
- `daemon.log_file` (if set) receives only asyncssh/asyncio debug noise. No
`print`/`log` call path in this codebase carries decoded file bytes.
- `content_b64` is base64; callers must decode and protect it.
-- `token` returned by `start` is the authorization handle for `stop`/`status`.
- Store it like a credential.
- Private keys (`ssh_pkey`) stay in process memory; they are never written
to `~/.ssh` or to a tempfile. Parsing happens via
`asyncssh.import_private_key`.
+- A caller-supplied `--session-dir` must be owned by the invoking user; tunstrap
+ clears its group/other write bits on use, because it stores 0600 credentials
+ (`tunnel-data/`) there. No pre-`chmod` is required of the operator.
**On-disk materialization** (`daemon.materialize`)
@@ -396,8 +604,13 @@ tool itself never writes content to disk — the "content never to disk" guarant
is preserved.
When `materialize=true`: the patched kubeconfig (including embedded private keys)
-is written mode 0600 to `/tunnel-data/-`.
-The daemon removes these files on `stop` or `atexit`. The `path` field in the
+is written mode 0600 to `/tunnel-data/kube--`.
+Fetched files materialize to `fetch--`; these leaf names are an
+implementation detail, and consumers must read `path` from the output envelope
+rather than construct it.
+The daemon removes these files on `stop` or `atexit` — except when `stop` cannot
+confirm the daemon died, in which case it deliberately keeps them (see the `run`
+teardown notes above) and says so with `"preserved": true`. The `path` field in the
kube target output becomes non-null. Callers opting in accept that decoded files
(including private keys) land on disk until `stop`/`atexit` runs. If the daemon
is killed with `kill -9`, `tunnel-data/` is orphaned and must be cleaned up
@@ -426,7 +639,6 @@ future feature.
| `kube_targets[name]` missing or has error | Check `warnings[]` for SAN-probe details; try setting explicit `tls_server_name`. |
| `start` with a supplied `--session-dir` fails with "tunnel-data already exists" | Orphaned `tunnel-data/` from a previous `kill -9`. Remove it: `rm -rf /tunnel-data`. |
| `start` hangs | Node firewalled / DNS-stuck. Increase `ssh_options.connect_timeout` or remove the node. |
-| `status` says alive but `stop` says "token mismatch" | The PID was reused. Token guards against this — investigate which process holds the PID. |
## Migration from `v2026.10516.11702`
@@ -517,6 +729,7 @@ pytest tests/integration -m integration
## Project documents
+- Terragrunt / OpenTofu recipe: [`docs/recipe_terragrunt.md`](docs/recipe_terragrunt.md)
- Kube-targets design: `docs/specs/2026-05-30-kube-targets-design.md`
- Fetch-files design: `docs/specs/2026-05-20-feature-fetch-files-design.md`
- Original design (historical): `docs/specs/2026-05-16-tunstrap-design.md`
diff --git a/docs/recipe_terragrunt.md b/docs/recipe_terragrunt.md
new file mode 100644
index 0000000..01e42dd
--- /dev/null
+++ b/docs/recipe_terragrunt.md
@@ -0,0 +1,784 @@
+# Recipe: Terragrunt / OpenTofu through a tunstrap tunnel
+
+This recipe shows how to drive Terragrunt and OpenTofu (`tofu`) through a
+tunstrap tunnel using the **CLI-proxy model**: the shipped `tunstrap_tofu`
+console entry point installed as Terragrunt's `terraform_binary`, which brings
+the tunnel up and runs `tofu` with the connection details in the environment.
+
+It is written for someone adopting this in their own repo. It carries the
+measured facts a future reader would otherwise have to re-derive, the failure
+modes you will hit, and an honest statement of what is and is not proven.
+
+> **Companion design.** The full design — why this shape over the alternatives
+> (`--owner` watchdog, `--output-file`, `--placeholder-host`) — lives in
+> [`docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`](specs/2026-07-31-run-env-io-and-tofu-proxy-design.md).
+> The e2e tier that proves the central claim lives in `tests/e2e/`.
+
+## The model in one paragraph
+
+Terragrunt lets you point `terraform_binary` at any executable. `tunstrap_tofu`
+is that executable. For commands that touch a live cluster (`plan`, `apply`, …)
+the proxy opens the tunnel and **becomes `tofu`'s parent**: it injects the
+connection details into `tofu`'s environment, waits for `tofu`, and tears the
+tunnel down in a `finally` — so the tunnel's lifetime is exactly the child's.
+For commands that do not need a tunnel (`init`, `-version`), and whenever no
+tunnel is wanted, the proxy `execvp`s `tofu` directly. tunstrap is never a
+daemon you normally start and stop by hand in this model; it owns the child and
+tears it down automatically. Whenever that teardown ends without a confirmed
+stop — it reports a failure, itself raises, or the recorded identity is
+unreadable — tunstrap keeps the session data instead of deleting it and prints
+the `tunstrap stop --session-dir …` command that finishes the job by hand. That
+command applies the same rule, so it is safe to repeat: it clears the tunnel
+data once the daemon is confirmed gone, and otherwise preserves it and reports
+`"preserved": true`. When the preserved directory is one tunstrap minted under
+`TMPDIR`, the diagnostic names it too — `stop` never removes its own
+`--session-dir` argument, so that directory is yours to delete once the daemon
+is dealt with. A caller-supplied session dir must be owned by the invoking user;
+tunstrap clears its group/other write bits on use, because it stores 0600
+credentials (`tunnel-data/`) there — no pre-`chmod` is required.
+
+`stop` exits 0 only when it clears `tunnel-data`: stopped, forced, or `not
+found`. It exits 1 for every outcome that preserves data: identity mismatch,
+identity check unavailable, still alive, identity changed during grace, and the
+three identity-read failures (missing, unreadable, or malformed `daemon.pid`).
+Repeating an unresolved recovery command keeps returning 1 until the session is
+resolved by hand; the loop's behaviour is unchanged, only its status, and a
+preserved session was never recoverable through repetition alone.
+
+## Prerequisites
+
+- `tunstrap_tofu` on `PATH` (the installed proxy entry point — see Installation
+ below for a one-line install). `tofu` on `PATH` too (the proxy `execvp`s it
+ for the pass-through branches and as the tunnelled child).
+- An OpenTofu/Terraform module that reads the connection details from a
+ `TF_VAR_*` variable. The shape is given below and is load-bearing.
+
+## Installation
+
+One piece, installed once: the `tunstrap` package, which ships **two** console
+entry points — `tunstrap` and `tunstrap_tofu` (the proxy).
+
+tunstrap is **not on PyPI** — a direct-reference dependency (the asyncssh fork
+the package is built on) blocks publishing — so install it from the git source
+or a local checkout:
+
+```sh install
+uv tool install "git+https://github.com/AlexMKX/tunstrap.git"
+# or from a local checkout:
+uv tool install /path/to/tunstrap
+```
+
+Use `uv tool install`, not `uvx`. `uv tool install` yields **stable** entry
+points at `~/.local/bin/tunstrap` and `~/.local/bin/tunstrap_tofu`, identical
+across reinstalls; `uvx` runs from an ephemeral `~/.cache/uv/archive-v0/…` path
+that changes per resolution. There is nothing to copy into your repo: point
+`terraform_binary` at the installed `tunstrap_tofu` and you are done.
+
+## How the proxy works
+
+`tunstrap_tofu` is a thin dispatcher around `tofu` with three branches, decided
+from `argv` and `TUNSTRAP_INPUT`:
+
+| Condition | Branch | What happens |
+|---|---|---|
+| `TUNSTRAP_INPUT` unset | pass-through | `execvp tofu "$@"` — no tunnel, no `tunstrap`. This is the "infra not applied yet" path: Terragrunt omits the env_var, so the proxy is a transparent `tofu` wrapper. |
+| subcommand is `init`/`version`/`validate`/`fmt`/`-version`/`-help`, or no subcommand | pass-through | `execvp tofu "$@"` — same. `init` only configures the backend and downloads providers; `validate` checks the configuration against installed provider schemas only; `fmt` touches only local `.tf` files — none of the three ever reach the cluster API. Skipping them avoids a redundant tunnel per `terragrunt plan`/`validate`/`fmt` (Terragrunt's `extra_arguments.env_vars` reaches the listed commands **and** their automatic `init`). The subcommand is parsed past global flags, so `tofu -chdir=DIR init` also bypasses (see "A fixed gap" below). **Note:** this bypasses `TUNSTRAP_INPUT` even if you deliberately list `validate`/`fmt` in `commands` below — both are provably cluster-free, so the proxy does not build a tunnel for them regardless of that opt-in. |
+| otherwise | tunnelled | opens the tunnel, injects `TF_VAR_tunstrap` (the connection envelope) plus the scalar env, runs `tofu`, and tears the tunnel down in a `finally` — so the tunnel's lifetime is exactly the child's. Reuses `tunstrap run`'s hardened path in-process; no second process level. |
+
+**Never write to stdout.** Terragrunt captures and labels `tofu`'s stdout by
+default, and `terragrunt output -json` consumers parse it. Diagnostics go to
+stderr or a file. `tunstrap run` is silent on stdout after the child starts.
+
+**`KUBECONFIG` is suppressed in the child environment, not on the command line —
+and only `KUBECONFIG`.** For a single-node payload `run` injects `KUBECONFIG`
+(pointing at the same materialized file `config_path` would use); left in place
+it is a **silent fallback for `var.tunstrap`-driven configs (Mode B, below)** —
+if the `TF_VAR_tunstrap` → `config_path` wiring were broken, providers would
+still find a working cluster via `KUBECONFIG` and everything would appear fine.
+The proxy sets `suppress_kubeconfig`, so `run` builds the child environment with
+the *injected* `KUBECONFIG` removed, making the decoded `config_path` the
+**only** route to the cluster for a Mode B config. `KUBE_CONFIG_PATH`/
+`KUBE_CONFIG_PATHS` — the provider-facing names Mode A (below) relies on — are
+**not** touched by this guard: providers never read plain `KUBECONFIG` at all,
+so suppressing it protects a different, real audience instead —
+`kubectl`/`helm` CLI invocations inside `local-exec` provisioners, which do
+honour it. **Mode A works through `tunstrap_tofu` as documented**: any
+inherited `KUBECONFIG`/`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from the operator's
+own shell is also always dropped before `run` injects the real channel, on both
+the plain and the proxied path, so a stray operator environment can never leak
+into or override it either. The e2e tier proves the suppression by recording
+`tofu`'s actual environment and asserting `KUBECONFIG` is absent.
+
+### Wiring it into Terragrunt
+
+`terraform_binary` takes a **path only** (see "Measured Terragrunt facts"
+below). The **default** is a literal absolute path — nothing runs at config
+parse time and there is one less moving part. Find the path once with
+`command -v tunstrap_tofu` (`uv tool install` places it at
+`~/.local/bin/tunstrap_tofu`) and paste it:
+
+```hcl terragrunt-root
+# root.hcl - shared across units; each unit inherits it with
+# include "root" { path = find_in_parent_folders("root.hcl") }
+# (see the unit block below). terraform_binary is a TOP-LEVEL attribute, not a
+# member of the terraform {} block: TG rejects it there with "An argument named
+# terraform_binary is not expected here." See "Measured Terragrunt facts" below.
+# For a single-unit repo you may instead put this line at the top of the unit's
+# terragrunt.hcl and drop the include.
+#
+# Default: the absolute path of the installed tunstrap_tofu entry point.
+terraform_binary = "$HOME/.local/bin/tunstrap_tofu"
+```
+
+#### Localizing the bootstrap with `run_cmd` (optional)
+
+If you would rather the bootstrap lived entirely in `terragrunt.hcl` — no pasted
+path to update after a reinstall — `terraform_binary` also accepts `run_cmd`,
+which resolves the entry point at run time:
+
+```hcl terragrunt-root-runcmd
+# Optional: resolve the installed tunstrap_tofu at run time instead of pasting
+# the path. run_cmd execs its first arg directly (no shell), so go through
+# `sh -c` to use the POSIX `command -v` builtin.
+#
+# "--terragrunt-quiet" is LOAD-BEARING and it is the first argument to run_cmd
+# itself, not a terragrunt CLI flag. run_cmd consumes it to suppress logging the
+# command's output; without it, that output (the resolved path) is prepended to
+# every `terragrunt output -json`, corrupting the JSON. Measured against
+# Terragrunt v1.1.1:
+# run_cmd("--terragrunt-quiet", "sh", "-c", "command -v tunstrap_tofu") -> clean
+# run_cmd("sh", "-c", "command -v tunstrap_tofu") -> path leaks
+# This is the same shape as `env -u KUBECONFIG` in the old shell shim: drop the
+# incantation and the failure surfaces somewhere that looks unrelated.
+terraform_binary = run_cmd("--terragrunt-quiet", "sh", "-c", "command -v tunstrap_tofu")
+```
+
+`run_cmd`'s real costs: it runs **before** Terragrunt's `-version` probe, is
+cached once per `plan`, and runs once **per unit** under `run --all` — and it
+needs the marker. The literal-path default has none of those moving parts, which
+is why it is the recommendation.
+
+A `before_hook` **cannot** install the proxy instead. Terragrunt probes
+` -version` roughly 50 ms *before* any hook runs, and with the
+binary missing it fails outright before the hook executes (measured; the hook's
+marker file never appears).
+
+### Alternative: a shell shim for the fast path
+
+For the unusual consumer for whom every millisecond of the fast path matters,
+`tunstrap_tofu` costs ~25 ms per pass-through invocation (vs ~2 ms for a shell
+`exec`) — about ~74 ms added per `terragrunt plan`, noise beside an 8 s
+`tofu init`. A 3-line `/bin/sh` shim recovers the ~2 ms fast path at the cost of
+copying, committing and keeping it in sync (it is not driven by the e2e tier;
+`sh -n`-checked and smoke-tested as a labelled fence below —
+`tofu-shim-alt`). For nearly everyone the entry point is the better trade.
+
+```sh tofu-shim-alt
+#!/bin/sh
+# Lower-overhead alternative to tunstrap_tofu: a shell exec on the fast paths
+# (~2 ms vs ~25 ms). Copy into bin/tofu-tunstrap, chmod 0755, commit. Behaves
+# the same as the proxy EXCEPT it cannot parse past -chdir to a global flag, so
+# `tofu -chdir=DIR init` builds a needless tunnel (the fixed gap, unfixed here).
+[ -n "$TUNSTRAP_INPUT" ] || exec tofu "$@"
+case "$1" in init|-version) exec tofu "$@" ;; esac
+exec tunstrap run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap \
+ -- env -u KUBECONFIG tofu "$@"
+```
+
+Then, in the unit that needs the tunnel, declare `TUNSTRAP_INPUT` as an
+`extra_arguments` env var scoped to the commands that actually contact the
+cluster. The unit **must** inherit the root for `terraform_binary` to take
+effect — without `include "root"`, Terragrunt silently falls back to plain `tofu`
+on `PATH` and the apply dies at the inert `https://127.0.0.1:0` endpoint (see
+"Failure modes" — this is indistinguishable from a forgotten `commands` entry by
+the exit code alone, and the recipe exists to keep them apart):
+
+```hcl terragrunt-unit
+# unit terragrunt.hcl
+#
+# `terraform_binary` lives in root.hcl; inherit it. find_in_parent_folders
+# defaults to searching for "terragrunt.hcl", so name "root.hcl" explicitly -
+# the bare find_in_parent_folders() errors with ParentFileNotFoundError when the
+# parent is root.hcl (measured).
+include "root" {
+ path = find_in_parent_folders("root.hcl")
+}
+
+terraform {
+ source = "."
+
+ extra_arguments "tunstrap" {
+ # Commands that make provider API calls. init/validate/output are
+ # intentionally absent: they read state/files or the registry, not the
+ # cluster. import IS included - it reads a live resource and is the easiest
+ # state-mutating command to forget.
+ commands = ["plan", "apply", "destroy", "refresh", "import"]
+ arguments = []
+
+ # dependency.* resolves here [measured]; it does NOT resolve in `locals`,
+ # so the conditional must be inline in env_vars, not factored out.
+ env_vars = local.cluster_host != "" ? {
+ TUNSTRAP_INPUT = jsonencode({
+ nodes = {
+ node = {
+ host = local.cluster_host
+ port = 22
+ user = "root"
+ ssh_pkey = local.ssh_private_key
+ remote_targets = { k3s = "127.0.0.1:6443" }
+ kube_targets = { k3s = { kubeconfig_path = "/etc/rancher/k3s/k3s.yaml" } }
+ required = true
+ }
+ }
+ daemon = {
+ shutdown_grace_seconds = 10
+ materialize = true
+ # auto_stop_idle_seconds is intentionally absent: the daemon's
+ # lifetime is now exactly the tofu child's.
+ }
+ })
+ } : {}
+ }
+}
+
+```
+
+The unit references `local.cluster_host` / `local.ssh_private_key`, which it does
+not define, so a copy-paster gets `"local.cluster_host is not defined"` until they
+add a `locals` block. `dependency.*` does NOT resolve in `locals` (measured
+below), so build these from your source of truth and keep any `dependency.*`
+reference inline in `env_vars` above. Expected shape (uncomment and fill):
+
+```hcl terragrunt-locals
+# locals {
+# cluster_host = "k3s.example.internal"
+# # PEM-format PRIVATE key (fed to asyncssh.import_private_key) - NOT the
+# # ssh-ed25519 AAAA... .pub line. Pull it from your secret store rather
+# # than committing it inline.
+# ssh_private_key = get_env("TUNSTRAP_SSH_PRIVATE_KEY", "")
+# }
+```
+
+When `local.cluster_host == ""` (infra not applied, or a mock-state run), the
+`env_vars` map is empty, `TUNSTRAP_INPUT` is unset, and the proxy takes its
+pass-through branch — so the same unit plans cleanly with no tunnel and
+no mock-state workaround. This replaces the entire `--placeholder-host` idea:
+`env_vars` is an ordinary HCL map, so "no tunnel" is just "key omitted".
+
+### The `commands` list is an enumeration, not a copy of an old hook list
+
+If you are migrating from a `run_cmd`/`after_hook` design, do **not** copy the
+old hook's command list. That list answered "which commands evaluated `inputs`
+and therefore needed teardown". This list answers a different question: "which
+commands make provider API calls". Concretely:
+
+| Command | Tunnel | Why |
+|---|---|---|
+| `plan`, `apply`, `destroy`, `refresh` | yes | providers read and write live cluster state |
+| `import` | **yes** | reads the live resource to populate state; omitting it is a silent trap |
+| `console` | yes, if you use it | can evaluate provider data sources; add it if interactive |
+| `init`, `validate` | no | backend config / schema checks only; no cluster contact |
+| `output`, `show`, `state *`, `taint`, `untaint`, `fmt`, `providers` | no | read/rewrite state and files |
+
+`validate` and `fmt` are also in the proxy's own bypass set (see "How the proxy
+works," above): even if you list either in `commands`, the proxy still
+`execvp`s `tofu` directly for them rather than tunnelling, because both are
+provably cluster-free. Consequence: `tofu validate` therefore runs *without*
+`TF_VAR_tunstrap` set at all — keep a default on the variable, as the module
+below does (`default = ""`), or `validate` hits an unset-variable error.
+
+Everything not listed gets `TUNSTRAP_INPUT` unset and takes the proxy's
+pass-through branch, so the failure mode of forgetting a command is a **provider
+error against an inert loopback endpoint**, not a silent wrong result.
+
+## The module side
+
+The proxy hands the module the connection envelope as a JSON string in
+`TF_VAR_tunstrap`. The module decodes it and derives its provider `config_path`
+from it. This is the exact chain the e2e tier exists to prove:
+
+```hcl tf-module
+variable "tunstrap" {
+ type = string
+ default = ""
+ sensitive = true
+}
+
+locals {
+ # try() is load-bearing: jsondecode("") is an error, so a bare jsondecode
+ # would make `tofu plan` fail whenever the infrastructure is not applied yet
+ # (the empty-string default).
+ tunnel = try(jsondecode(var.tunstrap), { nodes = {} })
+ kubepath = try(local.tunnel.nodes.node.kube.k3s.path, "")
+
+ inert = local.kubepath == ""
+ kube_config_path = local.inert ? null : local.kubepath
+ kube_host = local.inert ? "https://127.0.0.1:0" : null
+ kube_ca_certificate = local.inert ? "" : null
+ kube_client_cert = local.inert ? "" : null
+ kube_client_key = local.inert ? "" : null
+}
+
+provider "kubernetes" {
+ config_path = local.kube_config_path
+ host = local.kube_host
+ cluster_ca_certificate = local.kube_ca_certificate
+ client_certificate = local.kube_client_cert
+ client_key = local.kube_client_key
+}
+
+provider "helm" {
+ kubernetes {
+ config_path = local.kube_config_path
+ host = local.kube_host
+ cluster_ca_certificate = local.kube_ca_certificate
+ client_certificate = local.kube_client_cert
+ client_key = local.kube_client_key
+ }
+}
+```
+
+Four things to get right, each of which the tier proved can pass for the wrong
+reason if dropped:
+
+1. **`try()` around `jsondecode`.** `jsondecode("")` errors. Without `try`,
+ every non-tunnelled command fails. The e2e tier has a dedicated inert-path
+ test for this.
+2. **The inert branch pins an unreachable host and empty cert material.** When
+ `kubepath == ""`, the providers get `host = "https://127.0.0.1:0"` with empty
+ cert/key fields so they cannot fall back to `$KUBECONFIG` or `~/.kube/config`.
+ This is what makes a forgotten `commands` entry fail loudly instead of
+ silently reaching a cluster some other way.
+3. **`path` comes from the materialized file, not from a hand-written path.**
+ `run` always forces `daemon.materialize = true`, so `nodes.*.kube.*.path`
+ is a real on-disk kubeconfig (mode 0600), patched so `server:` and
+ `tls-server-name` already point at the tunnelled port. The provider just reads it.
+4. **`sensitive = true` on the variable.** Defence in depth, not the fix — it
+ suppresses rendering in plan/apply output and diagnostics, but it does *not*
+ keep the value out of the plan file. See the note below.
+
+### What is, and is not, in `TF_VAR_tunstrap`
+
+`run` **projects** the envelope before exporting it on this channel. Each
+`kube` entry keeps:
+
+`path`, `context`, `endpoint`
+
+and **drops** `client_key_data` (a private key), `content_b64` (the whole
+patched kubeconfig, which embeds that key) and `client_certificate_data` (not a
+key, but it discloses the Kubernetes RBAC identity — CN is the username, O the
+groups).
+
+The reason is the consumer, not the transport: OpenTofu persists root-module
+variable values in the **plan file**, which pipelines routinely archive, and
+renders unmarked variables in diagnostics. `sensitive = true` fixes the
+rendering half only — the plan file still contains the value — so the material
+has to not be there in the first place.
+
+Nothing is lost. `run` always materializes, so `path` points at an on-disk
+kubeconfig (mode 0600) that contains every dropped field. A module that wants
+inline provider configuration rather than `config_path` reads
+`certificate_authority_data` (a published trust anchor, not a credential),
+`tls_server_name`, and its own client credentials from that file directly —
+only `path`, `context` and `endpoint` travel through `TF_VAR_tunstrap` itself.
+
+`tunstrap start --output json` projects every materialized kube target through
+the same `path` / `context` / `endpoint` allow-list and every materialized
+fetched file through `{path, size, sha256}`. Without `--materialize`, each
+entry's `content_b64` is its only delivery channel, so that entry instead
+retains the complete envelope on stdout. Treat this unmaterialized mode's stdout
+as credential material; do not place it in CI logs or durable shell captures.
+
+### Fetched files are materialized, not carried in the envelope
+
+The projection above (kube) and this one (`fetch_files`) follow the same rule:
+`run` and materialized `tunstrap start --output json` materialize content to
+disk under the session dir's `tunnel-data/`, mode `0600`, and their
+consumer-facing envelope carries only a reference to it. Each `fetch_files`
+entry becomes `{path, size, sha256}` on success, `{error}` on failure — never
+`content_b64`.
+
+`FetchedFile` **has a `path`** (`schemas.py`, extended for this ticket), so
+the lossless on-disk alternative exists, the same way it already existed for
+kube.
+
+**The plan-file-persistence risk is resolved as a class, not documented
+around**: since fetched content never enters `TF_VAR_tunstrap` or the
+materialized file at all, `--fetch`ing a secret cannot land it in a saved
+Terraform plan file through this channel. Read the file directly at
+`fetch_files..path` if you need its contents.
+
+One free-form string rides this channel unprojected: `warnings[*].error`.
+It is exception text from an optional-node or kube-target failure (`manager.py`,
+`kube.py`) — connection, auth or TLS messages, not key material — so it is left
+intact rather than truncated.
+
+### The input variable is scrubbed
+
+The variable named by `--input-env` — `TUNSTRAP_INPUT` in the proxy above —
+holds the `InputSchema`, including `ssh_pkey`. `run` removes it from the child's
+environment before exec'ing `tofu`, because `tofu` passes its environment to
+every provider plugin, `external` data source and `local-exec` provisioner.
+Nothing in the module needs it; if you need a value from the payload downstream,
+export it explicitly rather than relying on inheritance.
+
+If you have more than one node in the payload, the kube env channel —
+`KUBECONFIG`/`KUBE_CONFIG_PATH(S)` — is still injected: it is unconditional
+on node count and aggregates every kube target across every node into one
+file list. Only the old `TUNSTRAP__*` scalars — a concept that no
+longer exists — were ever suppressed for multi-node. `TF_VAR_tunstrap` is set
+unconditionally too, and the module picks the node out of `nodes[]`.
+See the `--output-var` rules in the README.
+
+## Mode A: env-native kube (satisfies the ticket's strict "nothing live enters Terraform" contract)
+
+The module above reads its kube identity from `var.tunstrap`, which is
+connection data travelling through a Terraform input variable — exactly what
+ticket #15 asked to stop. Mode A is the alternative that actually satisfies
+that contract: no `var.`-bound value, no file read in HCL at all, for kube.
+
+**Mode A works through `tunstrap_tofu`**, the proxy this recipe recommends as
+`terraform_binary` — that is the point of it. The proxy's
+`suppress_kubeconfig` guard only removes the injected `KUBECONFIG`; it never
+touches `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`, so item 1 below reaches a
+provider block unfiltered whether you invoke `tofu` directly or through the
+proxy (see "How the proxy works," above, for why).
+
+1. `run` exports `KUBE_CONFIG_PATH` (one kube target) or `KUBE_CONFIG_PATHS`
+ (two or more) from its own process environment, unconditionally — see "The
+ input variable is scrubbed," above. A provider block that sets nothing but
+ a literal `config_context = "tunstrap--"` per alias resolves
+ its `config_path`/`config_paths` from those env vars alone, via the
+ provider's own `EnvDefaultFunc`. A two-alias worked example, one provider
+ block per kube target, sharing the same `KUBE_CONFIG_PATHS` list:
+
+ ```hcl
+ provider "kubernetes" {
+ alias = "node1_k3s"
+ config_context = "tunstrap-node1-k3s" # literal -- never derived from var.tunstrap
+ }
+ provider "kubernetes" {
+ alias = "node2_k3s"
+ config_context = "tunstrap-node2-k3s"
+ }
+ ```
+
+2. **Warning, explicit:** never derive `config_context`'s value from
+ `var.tunstrap` or any other live/decoded data. It must be a literal string
+ in the config, matching the deterministic naming scheme exactly
+ (`tunstrap--`). Deriving it live would reintroduce a
+ variable-bound value for data that has an env-native, fully static
+ alternative, defeating the point of Mode A.
+
+3. **Measured facts a consumer needs**, restated from the ticket's own six
+ findings plus this design's provider findings, not re-derived:
+
+ - **#1** — provider configuration **is** re-evaluated at apply.
+ - **#2** — outputs **freeze silently**: `file()` read through an output
+ returns the plan-time value at apply, with no error — the nastiest
+ failure mode, name it as such.
+ - **#3** — per-alias `config_context` works with an env-supplied
+ kubeconfig path (Mode A's own basis, shown in item 1's example).
+ - **#4** — plan-safe end to end, measured live in the unpublished #15 spike:
+ plan with one set of
+ ports, mutate only the kubeconfig, apply the *saved* plan → the alias
+ uses the mutated value, zero "Mismatch between input and plan variable
+ value" — the e2e-level confirmation that Mode A's env-native path
+ really is plan-safe across a saved-plan reuse, not just a theoretical
+ consequence of finding #1.
+ - **#5** — `KUBE_CONFIG_PATHS` is colon-separated on Linux (comma
+ silently falls back to `localhost:80`).
+ - **#6** — a live value bound to a `var.` **does** trip "Mismatch between
+ input and plan variable value" on a saved plan (Mode B's one-shot rule
+ rests on this).
+ - A live value bound to a **resource attribute** (not a provider config
+ block) produces `Error: Provider produced inconsistent final plan` —
+ confirmed for `hashicorp/kubernetes` v2.38.0
+ (`docs/specs/2026-08-10-issue15-provider-env-precedence.md`, Q3).
+ Provider-block placement, as shown in item 1's example, is the only
+ supported shape in both Mode A and Mode B.
+
+4. The `config_context` values above follow tunstrap's deterministic naming
+ scheme (`tunstrap--`) exactly — the same names
+ `rename_identities` writes into the materialized kubeconfig. That matters
+ beyond providers: anyone who pipes the materialized file straight into
+ `kubectl --context` instead of through a provider block uses the same
+ literal context names.
+
+## Mode B: unified-file convenience (ports + kube references; does NOT satisfy the ticket's strict contract)
+
+A real consumer may use Mode A for kube and Mode B for ports in the same
+module. Nothing here satisfies the ticket's strict "nothing live enters
+Terraform" guarantee — state that plainly to a reader, not glossed over.
+There is no literal, operator-pinned path and no `var.`-derived locator
+anywhere in this section: the session dir stays ephemeral unconditionally.
+
+5. **The shape**, a worked example reading the env-carried
+ `TUNSTRAP_OUTPUT_FILE` locator via Terragrunt's `get_env(...)` — no
+ `--session-dir` precondition and no operator-agreed path:
+
+ ```hcl
+ locals {
+ tunnel = try(
+ jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))),
+ { nodes = {} },
+ )
+ }
+
+ provider "kubernetes" {
+ config_path = local.tunnel.nodes.node1.kube.k3s.path
+ }
+ ```
+
+ Read directly inside the `locals` block that feeds the provider config —
+ never through an `output`, per finding #2.
+
+6. **Ports lose their integer form** (`"host:port"`, not a bare port
+ number) — the extraction idiom:
+
+ ```hcl
+ locals {
+ service1_port = split(":", local.tunnel.nodes.node1.ports.service1)[1]
+ }
+ ```
+
+7. **The stability contract**, restated plainly: **both** Mode B forms —
+ item 5's `TUNSTRAP_OUTPUT_FILE` form and the `--output-var`
+ (`var.tunstrap`) form — are **one-shot `plan && apply` only**, with no
+ saved-plan reuse across a tunstrap restart for either and no locator
+ exemption of any kind (the check compares the variable's whole value; the
+ file itself is deleted at teardown alongside the rest of `tunnel-data/`).
+ Findings #1, #2 and #6 back this. Stated as plainly as the design doc
+ states it: *"Neither Mode B form survives a tunstrap restart. If you need
+ a saved plan to apply cleanly against fresh ports or fetched-file
+ content, re-run plan in the same tunstrap invocation."*
+
+8. **`jsondecode`, not JavaScript.** Consumption is via HCL's `jsondecode`;
+ there is no JS runtime anywhere in this stack (ADR entry 12).
+
+9. **Fetched files:** *"Fetched file content never enters a Terraform
+ variable or plan file — only its path, size, and checksum do. Read the
+ file itself at `fetch_files..path` if you need its contents."*
+
+## Measured Terragrunt facts
+
+All measured 2026-07-31 against **Terragrunt v1.1.1** and **OpenTofu v1.12.5**.
+These are observations about that pair, not tunstrap invariants — but the whole
+recipe rests on them.
+
+1. **`terraform_command_line` does not exist.** The hook is `terraform_binary`
+ / `--tf-path` / `TG_TF_PATH`, and it accepts a **path only**:
+ `--tf-path "/tmp/wrapper.sh --flag"` fails on the `-version` probe with
+ `fork/exec /tmp/wrapper.sh --flag: no such file or directory`. This is why
+ `terraform_binary` resolves a path (whether the `tunstrap_tofu` entry point
+ or a shell-shim alternative), not a command-line template.
+2. **`inputs` are delivered to the child as `TF_VAR_` environment
+ variables** (JSON-encoded values), not `.tfvars.json`, not `-var-file`. This
+ is why the envelope travels as `TF_VAR_tunstrap`.
+3. **`extra_arguments.env_vars` reach the listed command *and* its automatic
+ `init`, but not `-version`.** So a `terragrunt plan` with
+ `commands = ["plan","apply"]` sets your env var for the auto-`init` and for
+ `plan`, but leaves it unset for the `-version` probe — which is exactly why
+ the proxy's `-version` pass-through works without a tunnel.
+ 4. **`dependency.*` resolves inside `extra_arguments.env_vars`, but not inside
+ `locals`.** In `locals` you get `"dependency" is not defined`. This is why
+ the recipe keeps any `dependency.*` reference inline in the `env_vars` block.
+ 5. **Payloads survive byte-for-byte.** A ~10 KB JSON value arrived at both the
+ auto-`init` and `plan` with identical length and SHA-256 — no truncation, no
+ `E2BIG`. Multi-line content with PEM delimiters, `"` and `$` arrived
+ byte-identical. So embedding SSH private keys in the payload is safe at the
+ transport level (see "Security" for the stronger alternative).
+
+## Failure modes you will hit
+
+Both of the first two land at the same symptom — a provider error against
+`https://127.0.0.1:0`, the module's inert branch — so they are easy to confuse.
+They have different causes, and the recipe's job is to keep them straight:
+
+- **A unit that forgot `include "root"`.** The root's `terraform_binary` is not
+ inherited, so Terragrunt silently falls back to plain `tofu` on `PATH`. The
+ `extra_arguments.env_vars` still delivers `TUNSTRAP_INPUT` to that `tofu`, but
+ nothing sets `TF_VAR_tunstrap` (the proxy never runs), the module takes its
+ inert branch, and the provider dials `127.0.0.1:0`. Tell-tale: a recording
+ `tofu` shows `TUNSTRAP_INPUT` **present** but `TF_VAR_tunstrap` **absent**.
+ Fix: add the `include "root" { path = find_in_parent_folders("root.hcl") }`
+ block to the unit.
+- **A `commands` entry you forgot.** The command runs without `TUNSTRAP_INPUT`
+ (the list controls delivery), so `TF_VAR_tunstrap` is absent for the same
+ reason, the module takes its inert branch, and the provider errors against
+ `https://127.0.0.1:0`. Tell-tale: a recording `tofu` shows `TUNSTRAP_INPUT`
+ **absent** for that command. That is the *designed* loud failure — add the
+ command to the list. `import` is the classic omission.
+
+ The two are distinguished by whether `TUNSTRAP_INPUT` reached `tofu`: present
+ ⇒ missing include; absent ⇒ missing `commands` entry. Both are loud (non-zero
+ exit, a named endpoint); neither is a silent wrong result.
+- **A `--` you forgot inside `tunstrap run`.** This is a `run`-level rule. The
+ proxy always passes `--` for the user, so a consumer driving `tunstrap_tofu`
+ never hits it; it only surfaces if you hand-edit the `run` invocation (e.g. in
+ the shell-shim alternative). `--` is mandatory whenever the child command or
+ any of its arguments begins with `-`.
+- **A broken `config_path` chain that *succeeds*.** This is the silent one, and
+ it is what `suppress_kubeconfig` exists to prevent. If you ever see an apply
+ succeed after a wiring change you expected to break it, the first thing to
+ check is whether `KUBECONFIG` is still being cleared from the child env.
+- **An `init` that *builds* a tunnel.** If the proxy's `init` bypass stops
+ matching, you get two tunnels per `plan` (one for auto-`init`, one for the
+ plan itself). See "A fixed gap" below for one known way this used to happen.
+
+## A fixed gap: `tofu -chdir=DIR init` bypasses correctly
+
+The original consumer shell shim matched the bypass with a literal first token:
+
+```sh
+case "$1" in init|-version) exec tofu "$@" ;; esac
+```
+
+This matched `tofu init` and `tofu -version`, but **not** `tofu -chdir=somewhere
+init`, because the first token is `-chdir=…`, not `init`. So a `-chdir`
+invocation missed the bypass and built a tunnel it did not need — wasteful, not
+dangerous, and silent (a slower `init`, no error).
+
+**`tunstrap_tofu` closes the gap.** The proxy parses argv structurally past
+global flags (`-chdir DIR` and `-chdir=DIR`, both space and `=` forms), so
+`tunstrap_tofu -chdir=DIR init` correctly identifies `init` as the subcommand
+and bypasses. The bypass set is pinned exhaustively by a unit test
+(`test_should_bypass_*` in `tests/unit/test_tofu_proxy.py`), so a future edit
+that re-broadens or re-narrows it cannot pass silently.
+
+If you use the shell-shim alternative instead of the entry point, the gap
+returns — the shell `case` cannot parse past flags without becoming a substring
+match (which would wrongly bypass `tofu -chdir init plan`). The entry point is
+the recommended path precisely because it can make this distinction.
+
+## What is proven — and what is not
+
+The e2e tier (`tests/e2e/`) is real evidence for this design, but its scope is
+exact. Cite it for what it proves; do not let prose drift past it.
+
+**Proven by the e2e tier:**
+
+- Real `kubernetes` and `helm` providers reach a real cluster (a local `kind`
+ node) through a real tunstrap tunnel, via the
+ `--output-var` → `TF_VAR_tunstrap` → `try(jsondecode(...))` → `config_path`
+ chain.
+- The decoded `config_path` is the **only** route from the module to the
+ cluster. This was proven three ways: a valid path mutates real objects and the
+ used path is read back from state; an absent path fails naming the inert
+ `127.0.0.1:0` endpoint; a present-but-dead path fails naming the dead port.
+ Clearing `KUBECONFIG` is what makes these distinctions meaningful.
+- A dead endpoint surfaces as a non-zero exit.
+- `tofu`'s exit code propagates verbatim through `tunstrap run` (the tier proves
+ an exact code outside tunstrap's reserved set, not merely non-zero).
+- `tunstrap run` adds no bytes to `tofu`'s stdout (asserted byte-for-byte
+ against a direct-run oracle).
+
+**Not proven — do not imply:**
+
+- **Nothing about a remote cluster over a real network.** The entire tier runs
+ against a local `kind` node on one workstation. Latency, packet loss, and
+ real-network SSH behaviour are unexercised.
+- **Nothing about TLS, auth, timeout or 5xx failures.** The only failure mode
+ exercised is *connection-refused against a dead endpoint*. Other provider
+ failures may not render a port at all, and the "`config_path` is the route"
+ proof does not generalise to them.
+- **`terragrunt output -json` parsing IS now tested end-to-end** (in
+ `tests/e2e/test_terragrunt_apply.py`), in two configurations: with `output`
+ absent from `commands` (the pass-through proxy → `tofu`) and with `output`
+ added (the worst case: output routed through `tunstrap run`, proving tunstrap
+ run's own stdout survives a real Terragrunt consumer's parse — the property
+ the proxy's "never write to stdout" rule guards). **Still not tested:**
+ stdout purity for `plan`/`apply` under real Terragrunt (their stdout is the
+ plan/apply diff, consumed differently) and any consumer other than
+ `terragrunt output -json`. The worst-case test proves the purity property; it
+ does **not** recommend tunnelling `output` — the recipe keeps `output` out of
+ `commands` (it reads state, not the cluster).
+
+## Why a console script (now) — and what the consumer-file shim was protecting
+
+> **Decision reversed.** The proxy now also ships **in-package** as a second
+> console script, `tunstrap_tofu` (`tunstrap/tofu_proxy.py`), so
+> `uv tool install` produces both `tunstrap` and `tunstrap_tofu` and
+> `terraform_binary` can point at a stable installed path with nothing copied
+> into the consumer's repo. The consumer-file shell shim is retired from this
+> recipe (the e2e tier now drives `tunstrap_tofu`); it survives only as the
+> lower-overhead alternative the section after next mentions. The two agree on
+> every command the consumer deliberately opted into Terragrunt's `commands`
+> (the proxy must not veto that with a cluster-only allow-list of its own), and
+> both bypass `init`. The two deliberate differences: the proxy also bypasses
+> `version`/`-version`/`-help`/no-subcommand (harmless no-cluster cases the shell
+> pointlessly tunnelled), and it parses argv past `-chdir` so `tofu -chdir=DIR
+> init` bypasses correctly — closing the shell shim's documented gap. The
+> original three objections are kept below, each with its resolution, because the
+> trade is real and worth knowing before you choose between them.
+
+The three reasons the design originally argued for a consumer file, and where
+each stands now:
+
+1. **A Python console script pays interpreter startup on the fast paths.**
+ Real, and it kills the naive approach. Measured: the `sh` shim's fast path
+ costs ~2 ms; bare Python startup ~17 ms; Python plus `import tunstrap.cli`
+ ~225 ms (the import alone is ~184 ms by `-X importtime`). At 225 ms and
+ three fast-path invocations per `terragrunt plan`, a naive entry point that
+ imported `cli` on every call would add ~0.7 s per plan.
+ **The `tunstrap_tofu` entry point does not import `cli` (or anything heavy)
+ on the pass-through paths** — it `execvp`s `tofu` first — and
+ `tunstrap/__init__.py` resolves `__version__` lazily (PEP 562), so the
+ package import loads no `importlib.metadata` either. Measured fast path,
+ end-to-end via the installed entry: **~25 ms** (≈17 ms interpreter + a
+ now-cheap package import + the execvp handoff). That is about **12× the
+ ~2 ms shell shim**, i.e. **~74 ms added per `terragrunt plan`**, noise beside
+ an 8 s `tofu init`. (Before the lazy `__init__`, the same path was ~59 ms —
+ `importlib.metadata` contributed ~41 ms; making `__version__` lazy dropped
+ `import tunstrap` 67.3→17.5 ms and the pass-through 58.8→24.6 ms.) The cost
+ discipline is guarded by a unit test that imports only `tunstrap.tofu_proxy`
+ in a fresh interpreter and asserts none of `tunstrap.cli`/`click`/`pydantic`/
+ `asyncssh`/`cryptography`/`ruamel`/`importlib.metadata` loaded. The shell
+ shim remains cheaper (≈2 ms); a consumer for whom every millisecond of the
+ fast path matters can still write the 3-line shim — but for everyone else
+ ~25 ms is noise, and the entry point removes the copy/commit/drift entirely.
+2. **A committed file is the stable path `terraform_binary` wants.** Answered.
+ `uv tool install` yields a stable `tunstrap_tofu` entry point at
+ `~/.local/bin/tunstrap_tofu` (mirroring `tunstrap`), identical across
+ reinstalls; the ephemeral `~/.cache/uv/…` path is a `uvx` artefact only. So
+ the package entry point *is* a stable path, and `terraform_binary` can point
+ at it with no consumer-side file to copy, `chmod`, commit or drift.
+3. **It keeps Terraform vocabulary out of tunstrap.** This one is **being
+ consciously reversed by the owner.** `init`, `-version`, and `TF_VAR_` are
+ Terraform concepts, and the original design was structured to keep them in a
+ consumer shim, not in the package (see the spec's decision log, items 7 and
+ 20). That principle is now deliberately traded for the ergonomics of a
+ shipped entry point: `tunstrap/tofu_proxy.py` owns exactly that vocabulary.
+ The trade is recorded in the spec where the Terraform-free principle is
+ stated, not silently abandoned. The consumer-file shim is the escape hatch
+ for anyone who prefers the package to stay Terraform-free.
+
+**Which to use.** Prefer `tunstrap_tofu` (nothing to copy, stable path, the
+ `-chdir` gap above is fixed). Keep the consumer shim if you want the ~2 ms fast
+ path or want no Terraform vocabulary in the package you depend on.
+
+## Security
+
+Private keys still travel in the child's **environment** — but in this model
+they travel in `TUNSTRAP_INPUT` (an env var), never on the command line. That
+matters: Terragrunt's `ProcessExecutionError` joins the command and all
+arguments, so the old `run_cmd` design could print a PEM on any failure. The
+proxy model removes that exposure surface entirely.
+
+**The stronger option, recommended: use ssh-agent.** If you export
+`SSH_AUTH_SOCK` and drop `ssh_pkey` from the payload, key material leaves the
+payload altogether — tunstrap will use the agent. See
+[`docs/specs/2026-06-25-ssh-agent-fallback-design.md`](specs/2026-06-25-ssh-agent-fallback-design.md).
+It is not required by this recipe, but it is the right end state for any
+non-disposable environment.
+
+Two standing caveats, unchanged by this recipe:
+
+- **Host-key verification is not enforced** in this release. The tool targets
+ disposable/CI hosts on trusted networks. Do not use it over untrusted
+ networks until host-key pinning lands.
+- **Materialized kubeconfigs land on disk** (mode 0600, under
+ `/tunnel-data/`) for the lifetime of the child. `run` removes
+ them in its teardown; a `kill -9` of the daemon orphans them and you must
+ clean up manually (`rm -rf /tunnel-data`). This is inherent to
+ giving the provider a real `config_path`.
diff --git a/docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md b/docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md
new file mode 100644
index 0000000..f0c9529
--- /dev/null
+++ b/docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md
@@ -0,0 +1,1501 @@
+# `run` env I/O + the tofu proxy pattern
+
+> **Redaction/repoint note (2026-08-10):** Replaced an ignored local-artifact
+> path with its accurate unpublished description; no design claim changed.
+
+- Status: design, awaiting review
+- Date: 2026-07-31
+- Scope: three generic CLI additions (`--input-env`, `--output-var`, a
+ single-variadic `run` argument surface), the stdout and cleanup invariants a
+ foreground wrapper needs, a consumer-side `tofu` shim recipe, and a new `e2e`
+ test tier proving real Kubernetes/Helm providers through a tunnel. Supersedes
+ an untracked superseded owner-tracking design.
+- Measurement basis: Terragrunt **v1.1.1** + OpenTofu **v1.12.5**, and **Click
+ 8.4.2** (the version `click>=8.3,<9` in `pyproject.toml:12` resolves to today)
+ for the argument-parsing findings, and **kind 0.30.0 / `kindest/node` v1.34.0 /
+ Docker 28.1.1 / kubectl v1.28.1 / tunstrap 0.0.4** for the `e2e` tier. Linux,
+ all measured 2026-07-31. Every fact labelled *[measured 2026-07-31]* was
+ re-derived then; none is a repo invariant. Items the current code cannot yet
+ execute are labelled *[designed, unverified]*.
+- Code citations (`cli.py:NNN` etc.) are against **`ddde94d`**, the tip of `main`
+ at the time of writing. An in-flight `ValidationError`-leak fix on
+ `fix/validation-error-leak` shifts `cli.py` by +4 lines; re-resolve citations
+ against the merge base before implementing.
+
+## Problem
+
+The Terragrunt consumer (`consumer-repo/garuda/`) starts tunstrap from inside
+`inputs` via `run_cmd`, writes an `OutputSchema` to a marker file, reads it back
+with `jsondecode(file(...))`, and tears the daemon down in an `after_hook` —
+~150 lines of bash embedded in HCL (`terragrunt.hcl:11-14`, `:55-83`, `:221-237`,
+`:250-306`) plus `locals.tf:72`. Two consequences:
+
+1. **Secret exposure in a command line.** `run_cmd` offers argv only — no stdin.
+ The full `InputSchema`, including three `ssh_pkey` PEMs, is an argv element
+ (`terragrunt.hcl:257-305`). Terragrunt's `ProcessExecutionError` joins Command
+ and all args, so any failure of that `bash -c` prints the keys.
+2. **Lifetime is inferred, not owned.** Nothing is the daemon's parent, so the
+ `after_hook` is the only teardown authority and it misses every command not in
+ its list (`terragrunt.hcl:222`). The consumer compensates with
+ `auto_stop_idle_seconds = 7200` (`terragrunt.hcl:299`).
+
+The previous spec attacked (2) with a `--owner` watchdog. This spec removes the
+problem class instead: make tunstrap the **parent** of `tofu`.
+
+## Current state (as-is)
+
+- `start` takes input from a `USER@HOST[:PORT]` argument plus flags, or JSON on
+ stdin read whole at `cli.py:194` (`cli.py:172-211`). It emits the
+ `OutputSchema` envelope as JSON on stdout (`cli.py:219`) or, with
+ `--output env`, as `export K='V'` lines (`cli.py:215-217`).
+- `run` declares a **required** CONNECTION positional (`cli.py:251`) *followed by*
+ a variadic COMMAND (`cli.py:255`), plus the same flags; no stdin input path, no
+ `--output` flag. It merges the single-node scalar env into the child
+ environment (`cli.py:303`), launches the child with `Popen` (`cli.py:311-313`),
+ forwards `SIGINT`/`SIGTERM` (`cli.py:315-322`), and tears the session down in a
+ `finally` (`cli.py:327-330`, `_teardown_run` at `cli.py:334-342`).
+- **`run`'s teardown writes to stdout.** `_teardown_run` → `_kill_with_identity`
+ emits a `{"stopped": ...}` JSON line on stdout for *every* outcome
+ (`cli.py:376-437`). `stop_command` depends on that behaviour as its documented
+ contract; `run` inherits it as a defect. Unguarded: the `run` integration tests
+ assert only return codes and cleanup
+ (`tests/integration/test_cli_modes.py:127-197`).
+- **`run` has an unprotected post-spawn window.** `OutputSchema.model_validate`
+ and `render_env` run at `cli.py:302-304`, after `spawn_daemon` succeeded but
+ before the `try` that owns teardown opens at `cli.py:308`. Anything raised
+ there orphans the daemon.
+- `run` forces `daemon.materialize=True` regardless of the `--materialize` flag
+ (`force_materialize=True`, `cli.py:290`), because `render_env` requires
+ materialized kube paths (`envrender.py:42-43`).
+- The daemon flags `--auto-stop-idle-seconds`, `--materialize` and `--log-file`
+ are attached by `_connection_options` (`cli.py:75-77`) but deliberately
+ excluded from `_conn_flags_present` (`cli.py:84-93`).
+- `run` **cannot** read its payload from stdin: `Popen` is called without a
+ `stdin=` argument (`cli.py:311-313`), so the child inherits the parent's stdin
+ and a `sys.stdin.read()` would hand it a drained pipe. The one existing stdin
+ use in `run` is bounded to a single line for `--ssh-password-stdin`
+ (`cli.py:111-112`).
+- `render_env` requires exactly one node and raises a bare `ValueError`
+ otherwise (`envrender.py:19-20`). That `ValueError` has no exit-code mapping;
+ it would surface through `run`'s absence of a top-level guard as a traceback
+ (`start` has one at `cli.py:234-247`; `run` has none).
+- `OutputSchema` fields are `connections`, `pid`, `session_dir`, `started_at`,
+ `warnings` (`schemas.py:353-362`). `token` was removed by the 2026-06-24
+ session-reuse design.
+- Exit codes are `1` schema, `2` required/kube, `3` session-active, `4` daemon
+ (`exceptions.py:57-63`), plus `64` for usage via `_UsageExit64`
+ (`cli.py:33-49`). **`5` is unallocated.**
+- `--owner`, `--output-file` and `--placeholder-host` from the superseded spec
+ were **never implemented** — `grep -rn "owner|output_file|placeholder"
+ tunstrap/*.py` matches only unrelated identifiers in `kube.py:235,245`. They
+ are cancelled designs, not removals.
+
+## Design (to-be)
+
+### The I/O matrix
+
+`start` and `run` have an incomplete input/output matrix:
+
+| | input | output |
+|---------|--------------------------|---------------------------------|
+| `start` | stdin JSON · flags | stdout JSON · shell exports |
+| `run` | **flags only** | env → child (scalars only) |
+
+Two cells are missing and both are needed. The input gap is **not** a Terraform
+accommodation — it is forced by the shape of a foreground wrapper: `run` owns a
+child that inherits stdin (`cli.py:311-313`), so stdin is unavailable as a
+control channel, and the only remaining out-of-band input channel a parent has
+is its own environment.
+
+### Addition 1 — `--input-env VAR`
+
+Read the `InputSchema` JSON from `os.environ[VAR]` instead of from stdin or
+flags — illustrative surface only:
+
+```
+tunstrap run --input-env TUNSTRAP_INPUT -- CMD [ARGS...]
+```
+
+**Lands on `run` only.** `start` already has a working, uncontended input
+channel; the missing matrix cell is `run`'s. `start_command` already carries a
+three-way input conflict guard (`cli.py:159-179`) and an explicit
+`too-many-branches,too-many-statements` suppression (`cli.py:142`); a third
+input mode there would add branches for zero unmet need. The flag is additive
+and can be extended to `start` later. Parsing and validation reuse `start`'s
+stdin path verbatim (`cli.py:194-211`), with the same three
+`SchemaValidationError` shapes and exit 1.
+
+### Addition 2 — `--output-var NAME`
+
+Inject the `OutputSchema`, JSON-encoded, into the child's environment
+under `NAME`, alongside (not instead of) the existing scalar `TUNSTRAP_*` set.
+
+```
+tunstrap run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap -- CMD
+```
+
+Generic framing: *give the child the structure, not a flattened projection of
+one node*. The scalar env is lossy by construction — single-node
+(`envrender.py:19-20`), and it drops `warnings`, `started_at` and every
+`kube_targets` field except `path`/`endpoint`. `--output-var` is the structured
+channel; consumers wanting structure parse JSON, consumers wanting `KUBECONFIG`
+keep the scalars. It is **not** byte-identical to `start`'s stdout: the kube
+credentials are projected out before export (`render_output_var`, not
+`OutputSchema.model_dump_json()`), and `fetch_files[*].content_b64` passes
+through verbatim — see "Out of scope" and `docs/recipe_terragrunt.md`. `start`
+stdout is unchanged and still writes the complete envelope.
+
+`NAME` must match `[A-Za-z_][A-Za-z0-9_]*`, else usage error; `NAME` colliding
+with a `render_env` key is a usage error too, mirroring the collision discipline
+already inside `render_env` (`envrender.py:29-30`). Collision with an unrelated
+inherited variable is a documented overwrite.
+
+**Multi-node interaction.** `--input-env` makes multi-node input reachable from
+`run` for the first time. Rule: one node → scalars + `KUBECONFIG` as today, plus
+`--output-var` if given; >1 node with `--output-var` → `--output-var` only, no
+scalars; >1 node without `--output-var` → typed error, exit 1.
+
+That last case is decided **before `spawn_daemon`**, from `len(schema.nodes)` on
+the *input* schema — never after, where it would orphan a daemon (see "Cleanup
+must own the whole post-spawn window"). The bare `ValueError` at
+`envrender.py:20` still becomes a `TunstrapError` subclass with an exit code, as
+defence in depth for any path that reaches `render_env` with the wrong shape.
+
+### Addition 3 — `run`'s argument surface becomes a single variadic
+
+**Making `connection` merely `required=False` does not work, and would have made
+the documented shim invocation unreachable.** `run` declares CONNECTION as a
+positional *before* the variadic COMMAND (`cli.py:251`, `cli.py:255`). In Click,
+`--` terminates **option** parsing only; the tokens after it are still
+distributed over the declared positionals in order. Measured on Click 8.4.2
+[2026-07-31]:
+
+```
+run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap -- tofu plan
+ → connection='tofu' command=('plan',)
+```
+
+So `tofu` binds to CONNECTION, `plan` becomes the whole child command, and the
+spec's own "CONNECTION + `--input-env` → 64" rule then rejects the one
+invocation the shim must use. The positional pair cannot express "no connection".
+
+**Resolution: collapse the two positionals into one variadic.** CONNECTION stays
+a positional (no `--connection` option, no new subcommand — both would break the
+documented `run USER@HOST -- CMD` form in `README.md:175-195` and its integration
+tests), but the split is decided *after* parsing, by whether `--input-env` is
+present:
+
+```python
+@main.command("run")
+@_connection_options
+@click.option("--input-env", "input_env", default=None, metavar="VAR")
+@click.option("--output-var", "output_var", default=None, metavar="NAME")
+@click.option("--session-dir", "session_dir", default=None)
+@click.option("--grace-seconds", "grace_seconds", type=int, default=10, show_default=True)
+@click.argument("args", nargs=-1, type=click.UNPROCESSED)
+def run_command(..., input_env: str | None, output_var: str | None, args: tuple[str, ...]) -> None:
+ ...
+```
+
+Split rule:
+
+| `--input-env` | `args` | CONNECTION | child command |
+|---|---|---|---|
+| absent | `(conn, *cmd)` | `args[0]` | `args[1:]` |
+| present | `(*cmd,)` | — (none exists) | `args` |
+
+This is behaviour-preserving for flag mode. Measured on Click 8.4.2
+[2026-07-31] with the single variadic:
+
+```
+run user@host --ssh-key /k --target web=a:80 -- helm list
+ → args=('user@host','helm','list') ssh_key='/k' targets=('web=a:80',)
+run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap -- tofu plan
+ → args=('tofu','plan')
+run --input-env X -- tofu plan -out=x -var a=b
+ → args=('tofu','plan','-out=x','-var','a=b')
+run --input-env X -- env --ssh-key sneaky
+ → args=('env','--ssh-key','sneaky') ssh_key=None
+```
+
+Two properties this buys, both measured: option-looking child arguments after
+`--` are never absorbed by tunstrap (`-out=x`, `-var`, even `--ssh-key`), and the
+`--` position is *not* needed to find the split — Click consumes the first `--`
+and only the first (`run user@host -- -- helm` → `args=('user@host','--','helm')`,
+which is exactly why the existing strip at `cli.py:272-274` exists; it is
+retained, applied to the child-command slice).
+
+**`--` is mandatory whenever the child command or any of its arguments begins
+with `-`.** Without it Click parses those tokens as tunstrap options:
+`run --input-env X tofu -version` → `NoSuchOption: No such option '-v'`
+[measured 2026-07-31]. The shim always passes `--`; the docs must say so.
+
+### Conflict matrix for `run`
+
+Because env mode has no connection slot, the old "CONNECTION + `--input-env`"
+conflict cannot be expressed and is therefore **structurally impossible** rather
+than an error. The matrix is stated over `args` length instead:
+
+| `--input-env` | `args` | conn flags | result |
+|---|---|---|---|
+| absent | `len ≥ 2` | any | flag mode, unchanged |
+| absent | `len == 1` | any | usage **64** — "run requires a command: `tunstrap run USER@HOST ... -- CMD`" (today's message, `cli.py:276`) |
+| absent | empty | any | usage **64** — "run requires USER@HOST[:PORT] or `--input-env`" (today Click emits "Missing argument 'CONNECTION'"; message becomes explicit, code stays 64) |
+| present | `len ≥ 1` | none | env-input mode |
+| present | empty | any | usage **64** — "run requires a command" |
+| present | any | **any set** | usage **64** — "`--input-env` supplies the full InputSchema; connection flags are redundant" |
+
+"conn flags" is `_conn_flags_present` (`cli.py:84-93`): `--ssh-key`,
+`--ssh-key-passphrase`, `--ssh-password-stdin`, `--target`, `--kube`, `--fetch`.
+`--ssh-password-stdin` is additionally the one stdin consumer in `run`
+(`cli.py:111-112`), so its rejection under `--input-env` is doubly required.
+
+**Daemon flags.** `--auto-stop-idle-seconds`, `--materialize` and `--log-file`
+are attached by `_connection_options` (`cli.py:75-77`) but deliberately excluded
+from `_conn_flags_present` (`cli.py:84-93`), so they need their own rule. Chosen
+rule: **each is a usage error 64 under `--input-env`**, not an override and not
+silently ignored.
+
+| `--input-env` + | result |
+|---|---|
+| `--auto-stop-idle-seconds` | usage **64** — set `daemon.auto_stop_idle_seconds` in the payload |
+| `--log-file` | usage **64** — set `daemon.log_file` in the payload |
+| `--materialize` | usage **64** — redundant; `run` always forces it (below) |
+
+Silent precedence between two authorities is the worst outcome for a caller
+debugging a tunnel: the `InputSchema` already carries a complete `daemon` block
+(`schemas.py:270`), so there must be exactly one place to look. Rejecting is
+cheap and reversible; a precedence rule is neither.
+
+**Invariant: `run` forces `daemon.materialize = True`, including on an
+`--input-env` payload.** Today `run` passes `force_materialize=True`
+(`cli.py:290`), so the flag mode cannot produce unmaterialized kube targets. An
+env payload can say `materialize: false`, and then `render_env` raises "kube
+target not materialized; cannot set KUBECONFIG" (`envrender.py:42-43`) — and,
+worse for the shim, `--output-var` would hand the consumer `path: null` and the
+`kubernetes`/`helm` providers would get an empty `config_path`. So `run`
+overrides the payload's `materialize` to `True` unconditionally and documents it.
+This is the one place `run` mutates the supplied schema; it is an invariant of
+the verb, not a flag precedence rule.
+
+`--output-var` is orthogonal and composes with every row. Payload-state failures
+are separate and all exit 1 — see Error handling.
+
+### `run` must not write to stdout
+
+The shim's load-bearing rule is that **only the child's own output reaches
+stdout**, because Terragrunt parses tofu's stdout and `terragrunt output -json`
+consumers parse it downstream [measured 2026-07-31, fact 8]. `run` violates this
+today, in its teardown:
+
+`_teardown_run` (`cli.py:334-342`) calls `_kill_with_identity`, which writes a
+`{"stopped": ...}` JSON line **to stdout on every outcome** — success
+(`cli.py:399-402`, `:409-412`, `:430-433`, `:434-437`), `not found`
+(`cli.py:381-384`), `identity mismatch` (`cli.py:385-389`), `unavailable`
+(`cli.py:390-394`), and `identity changed during grace` (`cli.py:421-426`). Under
+the shim this line lands in the middle of tofu's output stream. The existing
+integration tests never catch it: `tests/integration/test_cli_modes.py:127-197`
+assert only `returncode` and that `tunnel-data` is gone.
+
+Fix: split the mechanism from its reporting.
+
+```python
+# identity/session-side primitive: performs the stop, writes nothing.
+def stop_session(session_dir: str, pid: int, grace_seconds: int, *, force: bool) -> StopOutcome:
+ ... # returns e.g. StopOutcome(stopped=bool, reason=str|None, forced=bool)
+```
+
+- `stop_command` (`cli.py:345-358`) calls it and renders **exactly today's JSON
+ on stdout**, key for key — `{"stopped": false, "reason": "not found"}`,
+ `{"stopped": true, "forced": true}`, and so on. `stop`'s stdout is its
+ documented contract and is unchanged.
+- `_teardown_run` calls it and prints **nothing on success**. A failed teardown
+ is a real diagnostic and goes to **stderr**, never stdout.
+- **The failure diagnostic must be reachable.** `SessionDir.cleanup_path` is
+ `shutil.rmtree(data, ignore_errors=True)` (`session.py:132-135`), which
+ swallows every filesystem error — so a promise to report cleanup failures on
+ stderr would be unsatisfiable as long as `run` calls it unchanged. Reconciled
+ by making `cleanup_path` **report without raising**: it returns an outcome
+ (e.g. the list of paths it could not remove) and still never propagates an
+ exception, so `stop_command`'s behaviour is untouched while `run` has something
+ to print. Concretely, the two teardown failure sources are: a non-`stopped`
+ `StopOutcome` from `stop_session`, and a non-empty removal-failure list. Both
+ go to stderr; neither changes the exit code.
+- `read_identity` failure remains a silent branch (`cli.py:336-340`) — with (3)
+ above, a missing identity file no longer means the path is unknown.
+
+Combined with `run`'s existing stderr-only error paths (`cli.py:294`, `:299`,
+`:325`), this makes the invariant total: **after the child starts, tunstrap
+writes nothing to fd 1, ever.**
+
+### Cleanup must own the whole post-spawn window
+
+Today `run` does real work between a successful `spawn_daemon` and the `try`
+whose `finally` tears down: `OutputSchema.model_validate` and `render_env`
+(`cli.py:302-304`), with the `try` opening only at `cli.py:308`. Anything raised
+in that window orphans the daemon, and `run` has no top-level guard (`start` has
+one at `cli.py:234-247`). `--output-var` adds JSON encoding to the same window.
+
+Requirements:
+
+1. **All usage validation happens before `spawn_daemon`** — the whole conflict
+ matrix, `--output-var` NAME validity, and reading/parsing/validating the
+ `--input-env` payload. Nothing that can exit 64 or 1 may run after a daemon
+ exists.
+2. **The multi-node/`--output-var` check is pre-spawn too.** Node count is a
+ property of the *input* schema (`len(schema.nodes)`), not the output, so
+ `len(schema.nodes) != 1 and output_var is None` → exit 1 **before** spawning.
+ This removes the largest new orphan risk this design would otherwise add.
+3. **`run` mints the session path *before* spawning, and never learns it from
+ the payload.** Today `run` passes `session_dir` straight through to
+ `spawn_daemon`, and when it is `None` the **worker** generates it
+ (`session.py:create`, `tempfile.mkdtemp`), so the parent can only recover the
+ path by parsing the success envelope. That is the root cause of the orphan
+ window: cleanup depends on the very object whose validation can fail. Instead,
+ when the caller supplies no `--session-dir`, `run` creates one itself
+ (`tempfile.mkdtemp`) and passes it explicitly. `SessionDir.create` already
+ accepts a supplied absolute path and does `mkdir(parents=True,
+ exist_ok=True)`, so an empty pre-created directory is valid input. The session
+ path is then a **precondition of spawning**, known before the daemon exists
+ and independent of the payload.
+
+ Consequence to honour: a supplied path sets `generated=False`, so the worker
+ will not remove the directory root. `run` therefore removes its **own** minted
+ temp root after teardown, and never removes a caller-supplied `--session-dir`
+ (matching today's `_teardown_run`, which only clears `tunnel-data`).
+4. **One `try/finally` opens the instant `spawn_daemon` returns success**, with
+ *nothing whatsoever* between the two. It encloses `model_validate`,
+ `render_env`, `--output-var` encoding, `Popen`, signal handling and `wait`.
+ Because the session path came from (3), the `finally` can always locate and
+ stop the daemon — including when `model_validate` raises on a malformed
+ success payload, which is exactly the case an earlier draft of this spec left
+ unguarded.
+5. **Teardown must not be skippable by a failure in the `finally` itself.**
+ Signal-handler restoration (`cli.py:328-329`) precedes `_teardown_run`
+ (`cli.py:330`) in the same `finally`; if restoration raised, teardown would
+ never run. Restoration therefore goes in its own nested `try/finally` whose
+ `finally` performs the teardown, so the daemon is stopped regardless. The stop
+ primitive itself must not raise: unexpected exceptions inside it are caught,
+ reported on stderr, and **must not override the already-determined child exit
+ code** — a child that ran and returned 7 still exits 7 even if teardown
+ misbehaves.
+6. **A top-level guard on `run`** mirroring `start`'s (`cli.py:234-247`) but
+ writing the `DaemonError` envelope to **stderr** (never stdout, see above) and
+ exiting **4**.
+
+### Explicitly NOT done: generalizing `render_env` to multi-node
+
+`render_env` requires exactly one node (`envrender.py:19-20`) because
+`TUNSTRAP__*` has no node dimension: two nodes with a target named `k3s`
+collide irreducibly, and `2026-06-25-cli-run-modes-design.md:245` already places
+multi-node CLI input in Out of scope. `--output-var` serves the case with a
+channel that *has* a node dimension (`connections` is keyed by node), so the
+single-node contract stays exactly as documented — untouched and unbroken — and
+the codebase gains one fewer branch. The only `envrender.py` change is turning
+the bare `ValueError` into a typed error.
+
+### The tofu shim — consumer-facing, Terraform-specific, outside tunstrap
+
+```sh
+#!/bin/sh
+[ -n "$TUNSTRAP_INPUT" ] || exec tofu "$@"
+case "$1" in init|-version) exec tofu "$@" ;; esac
+exec tunstrap run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap \
+ -- env -u KUBECONFIG tofu "$@"
+```
+
+**Why `env -u KUBECONFIG`.** For a single-node payload `run` injects
+`KUBECONFIG` into the child (`envrender.py:48-49`), pointing at the same
+materialized file `config_path` would use. Left in place it is a silent
+fallback: if the `TF_VAR_tunstrap` → `config_path` wiring were broken or
+removed, the `kubernetes` and `helm` providers would still find a working
+cluster via `KUBECONFIG` and everything would appear fine. Clearing it makes the
+decoded `config_path` the **only** route to the cluster, so a broken chain fails
+instead of silently working. This restores a protection the consumer already has
+and this spec had dropped: the script being replaced does exactly this at
+`terragrunt.hcl:61-63` — *"Clobber $KUBECONFIG so neither tunstrap itself nor
+any child process probe can fall back to the operator's personal kubeconfig."*
+The same reasoning makes the e2e tier's central assertion meaningful rather than
+decorative (assertion 4 below).
+
+Both Terraform-specific decisions live here, not in tunstrap:
+
+- **Pass-through when `TUNSTRAP_INPUT` is unset.** Replaces the whole
+ `--placeholder-host` design: `env_vars` is an ordinary HCL map, so the
+ consumer omits the key entirely when infra is not applied.
+- **Skip the tunnel for `init` and `-version`.** `tofu init` configures the
+ backend and downloads providers; it contacts neither the k8s API nor Helm, and
+ the consumer's state backend is S3-compatible over the public internet
+ (`root.hcl:24-41`), not tunneled. Without the skip, [measured 2026-07-31]
+ env-var scoping (below) yields **two** tunnels per `terragrunt plan` — one for
+ the auto-`init`, one for `plan`.
+
+`exec` is correct in both pass-through branches (nothing to clean up). Nothing
+must `exec` *past* teardown, and nothing does: `tunstrap run` already owns the
+child via `Popen` + signal forwarding + `finally` teardown (`cli.py:306-331`).
+`exec`ing *into* `tunstrap run` is fine and desirable — one less process level,
+and Terragrunt's signals reach tunstrap directly.
+
+**The shim must never write to stdout.** Terragrunt captures and labels tofu
+stdout by default (`--tf-forward-stdout` changes this) and `terragrunt output
+-json` consumers parse it. Diagnostics go to stderr or a file.
+
+### Shipping the shim: consumer file *and* a console script (revised)
+
+> **Revised after this design landed.** The original recommendation was
+> "consumer keeps the shim in its own repo; the package ships none". The owner
+> has since reversed that: the proxy **also** ships in-package as a second
+> `[project.scripts]` entry, `tunstrap_tofu` (`tunstrap/tofu_proxy.py`), so
+> `uv tool install` yields both `tunstrap` and `tunstrap_tofu` and
+> `terraform_binary` points at a stable installed path with nothing copied into
+> the consumer's repo. The consumer-file shim is retired from the recipe and the
+> e2e tier (the tier now drives `tunstrap_tofu`); it remains only as the
+> lower-overhead alternative the recipe mentions for cost-sensitive consumers.
+
+The original three reasons for keeping the proxy out of the package, and where
+each stands after the reversal:
+
+(a) **Interpreter startup on the fast paths** — real, and it kills the naive
+approach. Measured (see `docs/recipe_terragrunt.md` "Why a console script
+(now)"): `sh` shim ~2 ms; bare Python ~17 ms; Python plus `import
+tunstrap.cli` ~225 ms (the import alone ~184 ms). The shipped `tunstrap_tofu`
+does **not** import `cli` on the pass-through paths — it `execvp`s `tofu`
+first — and `tunstrap/__init__.py` resolves `__version__` lazily (PEP 562), so
+the package import loads no `importlib.metadata` either. The measured fast
+path is **~25 ms end-to-end via the installed entry** (≈17 ms interpreter + a
+now-cheap package import + the execvp handoff) — about **12× the ~2 ms shell
+shim**, i.e. **~74 ms per `terragrunt plan`** at three fast-path hits, judged
+noise beside an 8 s `tofu init`. (Before the lazy `__init__`, the same path was
+~59 ms — `importlib.metadata` contributed ~41 ms; making `__version__` lazy
+dropped `import tunstrap` 67.3→17.5 ms and the pass-through 58.8→24.6 ms.) The
+discipline (no `cli`/`click`/`pydantic`/etc. on the pass-through paths, and no
+`importlib.metadata` either) is guarded by a unit test; the consumer-file shim
+remains the ~2 ms option for cost-sensitive consumers.
+
+(b) **Terraform vocabulary inside the package** — this is the trade being
+**deliberately taken**. `init`, `-version`, and `TF_VAR_` now live in
+`tunstrap/tofu_proxy.py`. The Terraform-free principle this design is structured
+around (decision-log items 7 and 20) is therefore no longer absolute; the
+reversal is recorded there, not silently applied. `cli.py` itself stays generic
+— the only vocabulary added there is a `suppress_kubeconfig` parameter, not
+any Terraform name.
+
+(c) **No stable distribution path** — answered. `uv tool install` yields a
+stable `tunstrap_tofu` entry point identical across reinstalls; the ephemeral
+`~/.cache/uv/…` path was always a `uvx` artefact, not a `uv tool install` one.
+
+The shipped entry point also closes the consumer shim's documented
+`tofu -chdir=DIR init` gap (see the recipe): it parses argv past global flags
+rather than matching a literal first token.
+
+### Measured Terragrunt facts
+
+All *[measured 2026-07-31, Terragrunt v1.1.1 / OpenTofu v1.12.5]*. These are
+observations about that pair, not repo invariants.
+
+1. `terraform_command_line` is **not** an attribute. The hook is
+ `terraform_binary` / `--tf-path` / `TG_TF_PATH`, and it is a **path only**:
+ `--tf-path "/tmp/.../wrapper.sh --dummy"` fails on the `-version` probe with
+ `fork/exec /tmp/.../wrapper.sh --dummy: no such file or directory`.
+2. `inputs` reach tofu as **`TF_VAR_` env vars with JSON-encoded values** —
+ not `.tfvars.json`, not `-var-file`. Observed:
+ `TF_VAR_secret_thing={"key":"-----BEGIN OPENSSH PRIVATE KEY-----\n..."}`. The
+ consumer's `ssh_private_key` is already in the child environment today.
+3. `terraform { extra_arguments "n" { commands env_vars } }` sets env vars for
+ the tofu child **and the shim itself receives them** — the crux.
+4. **Env-var scoping**, per-unit. `terragrunt plan` with
+ `commands = ["plan","apply"]`: `-version` unset, auto-`init` **set**, `plan`
+ set. `terragrunt output` (unlisted): all three unset.
+5. **Invocation counts.** `plan`/`apply`/`output`/`validate` → 3 tofu
+ invocations (`-version`, `init`, the command); `init` → 2; a `dependency`
+ adds 2 in the *dependency's own* unit (`init`, `output -json`), which never
+ see the dependent unit's `env_vars`. `-version` fires once per terragrunt
+ run, not once per unit.
+6. **`dependency.*` resolves inside `terraform { extra_arguments { env_vars } }`,
+ but not inside `locals`** (there: `"dependency" is not defined`). The
+ consumer's comment at `terragrunt.hcl:50-51` — "Terragrunt 1.0.x resolves
+ `dependency.*` only inside `inputs`" — is **too narrow** on 1.1.1. This is the
+ fact the whole design rests on.
+7. **Payload fidelity.** A 10,065-byte JSON value arrived at both auto-`init` and
+ `plan` with identical length and SHA-256 — no truncation, no `E2BIG`.
+ Multi-line content with PEM delimiters, `"` and `$` arrived byte-identical.
+8. Terragrunt labels and processes tofu stdout by default; anything the shim
+ writes there can corrupt `terragrunt output -json`.
+
+## Components touched
+
+| File | Change |
+|------|--------|
+| `cli.py` | `run`: replace the CONNECTION + COMMAND positional pair (`:251`, `:255`) with one `args` variadic and the post-parse split; add `--input-env VAR` and `--output-var NAME`; implement the full conflict matrix **pre-spawn**; build the schema from the env payload reusing `start`'s parse/validate path (`:194-211`); force `daemon.materialize=True` on that payload; **mint the session path before `spawn_daemon` so cleanup never depends on the payload**; inject `--output-var` into `child_env` beside `render_env(out)` (`:303`); move `model_validate`/`render_env` inside the teardown `try` (`:302-308`) with signal restoration in a nested `try/finally`; make `_teardown_run` silent on success and stderr-only on failure; add a stderr-only top-level guard (exit 4). |
+| `cli.py` (`stop`/`status`) | `_kill_with_identity` (`:376-437`) loses its `sys.stdout.write` calls; `stop_command` (`:345-358`) renders the identical JSON from the returned outcome, so `stop`'s stdout contract is byte-for-byte unchanged. |
+| `identity.py` or `session.py` | New silent `stop_session(session_dir, pid, grace_seconds, *, force) -> StopOutcome` primitive carrying the mechanism with no I/O. |
+| `session.py` (`cleanup_path`) | Return an outcome (paths it could not remove) instead of discarding every error via `ignore_errors=True` (`:132-135`); still never raises, so `stop` is unaffected, but `run`'s stderr diagnostic becomes reachable. |
+| `cli_input.py` | New `build_schema_from_env(var_name)` — read, JSON-decode, `InputSchema.model_validate`, all failures as `SchemaValidationError`. Mirrors the existing `build_single_node_schema` error discipline (`:113-123`). |
+| `envrender.py` | Replace the bare `ValueError` for multi-node (`:19-20`) with the new typed error. No multi-node rendering. |
+| `exceptions.py` | Add `MultiNodeEnvUnsupported(TunstrapError)` mapped to exit **1** in `_EXIT_CODES` (`:57-63`). No new exit code is needed; `5` stays unallocated. |
+| `README.md` | Document both flags and the mandatory `--` on `run`; link the new recipe; fix the three stale `token` references. |
+| `docs/recipe_terragrunt.md` | New. |
+| `pyproject.toml` (test config + `tunstrap_tofu` entry) | Add the `e2e` marker; `addopts` → `-m 'not integration and not e2e'`. **Revised (see "Shipping the shim"):** add the second console script `tunstrap_tofu = "tunstrap.tofu_proxy:main"`. No dependency changes. |
+| `tunstrap/tofu_proxy.py` (revised) | New module: the in-package `tunstrap_tofu` entry point. Pass-through branches `execvp` `tofu` without importing `cli`; the tunnelled branch delegates to `run` in-process via `run_via_env_input` with `suppress_kubeconfig=True`. Parses argv past global flags so `-chdir=DIR init` bypasses correctly. See "Shipping the shim (revised)". |
+| `tests/e2e/` | New, self-contained tier: `conftest.py` (own keypair + kind + compose lifecycle), `docker-compose.yml` (one `sshd-kube` on the external `kind` network), committed `_sshd_conf/allow_tcpfwd.conf`, `shim/tofu-tunstrap-novar` (the `--output-var` negative control; test-only), `module/` (providers + local chart), `test_tofu_providers.py`, `test_shim.py`, `test_terragrunt_apply.py`, `test_recipe_terragrunt.py`. **Revised:** the main consumer shim (`shim/tofu-tunstrap`) is retired; the tier drives the installed `tunstrap_tofu` entry point, not a copied shim. Borrows nothing from `tests/integration/`. |
+| `.gitignore` | Add `tests/e2e/_keys/` and `tests/e2e/_kube/` (both fixture-generated). |
+| `.github/workflows/test.yml` | New `e2e` job installing kind + tofu; not added to the coverage combine. |
+
+`pyproject.toml` now adds the `tunstrap_tofu` console script (revised — see
+"Shipping the shim"). `cli.py` gains only a generic `suppress_kubeconfig`
+parameter on `_build_child_env`/`_run_child`/`_supervise_child`/`run_command`
+and a generic `run_via_env_input` programmatic entry; **no Terraform vocabulary
+is added to `cli.py`** — the proxy's `init`/`TF_VAR_tunstrap`/`tofu` names live
+entirely in `tunstrap/tofu_proxy.py`.
+`daemon.py`, `_worker.py`, `schemas.py`, `manager.py`, `kube.py`, `fetcher.py`
+are untouched, and `OutputSchema` gains no fields — no `owner`, no
+`placeholder`.
+
+## Error handling
+
+Every row above the `spawn_daemon` line is evaluated **pre-spawn**, so none of
+them can orphan a daemon.
+
+| Condition | Phase | Channel | Exit |
+|---|---|---|---|
+| any row of the conflict matrix (incl. conn flags and daemon flags under `--input-env`) | pre-spawn | Click usage message, stderr | 64 |
+| `--output-var` NAME invalid, or colliding with a `render_env` key | pre-spawn | Click usage message | 64 |
+| named variable unset / empty / whitespace | pre-spawn | `SchemaValidationError` JSON on stderr | 1 |
+| named variable not JSON | pre-spawn | `SchemaValidationError` + `{"position": n}` | 1 |
+| JSON fails `InputSchema` | pre-spawn | `SchemaValidationError` + pydantic errors | 1 |
+| `len(schema.nodes) != 1` and no `--output-var` | pre-spawn | `MultiNodeEnvUnsupported` JSON on stderr | 1 |
+| required tunnel failed / session active / daemon error | spawn | existing paths (`cli.py:297-300`) | 2 / 3 / 4 |
+| anything raised post-spawn (`model_validate`, `render_env`, `--output-var` encode) | post-spawn, inside the `try` | `DaemonError` JSON on stderr, teardown runs | 4 |
+| child cannot be launched | post-spawn, inside the `try` | `cli.py:324-326` | 127 |
+| teardown itself fails (non-`stopped` outcome, or unremovable paths) | `finally` | diagnostic on **stderr** only | **never** changes the exit code |
+| child ran | — | child's code (`cli.py:331`) | child |
+
+**Every one of these channels is stderr.** `run`'s existing error paths already
+are (`cli.py:294`, `:299`, `:325`); this design closes the last stdout leak in
+the teardown (see "`run` must not write to stdout"). Under the shim, fd 1 belongs
+to tofu and to nothing else.
+
+## Testing (TDD)
+
+**Parser tests come first.** The argument-surface change (Addition 3) is the one
+place where a plausible-looking implementation silently mis-binds, so these are
+written before anything else and use the **exact documented shim invocation
+verbatim**, not a paraphrase:
+
+```python
+# must yield: connection=None, command=("tofu", "plan")
+["run", "--input-env", "TUNSTRAP_INPUT", "--output-var", "TF_VAR_tunstrap",
+ "--", "tofu", "plan"]
+```
+
+Plus, at parser level: `run user@host --ssh-key K --target web=a:80 -- helm list`
+→ connection `user@host`, command `("helm","list")`, flags bound (flag-mode
+regression); `run --input-env X -- tofu plan -out=x -var a=b` → command keeps all
+five tokens; `run --input-env X -- env --ssh-key sneaky` → command keeps
+`--ssh-key sneaky` **and** tunstrap's own `ssh_key` stays `None`;
+`run user@host -- -- helm` → command `("helm",)` after the existing strip
+(`cli.py:272-274`); `run --input-env X tofu -version` (no `--`) → usage error 64,
+documenting that `--` is mandatory for `-`-prefixed child arguments.
+
+Unit (`tests/unit/`):
+
+- `--input-env`: valid single-node JSON → schema equals the stdin-parsed
+ equivalent; valid multi-node JSON → N nodes; variable absent, empty or
+ whitespace → exit 1; malformed JSON → exit 1 with `details.position`;
+ schema-invalid JSON → exit 1 with `details.errors`.
+- Conflict matrix: every row → exit 64 **and** `spawn_daemon` asserted not
+ called — including each daemon flag (`--auto-stop-idle-seconds`, `--materialize`,
+ `--log-file`) under `--input-env`. No usage error may ever leak a daemon.
+- Forced materialize: an `--input-env` payload with `daemon.materialize=false`
+ reaches `spawn_daemon` with `materialize=True`.
+- `--output-var`: child env has NAME whose value round-trips through
+ `OutputSchema.model_validate`, scalars still present for one node; invalid
+ NAME and `render_env`-key collision → 64.
+- Multi-node: with `--output-var` → NAME present, **no** `TUNSTRAP_*` scalars,
+ `connections` carries every node; without it → exit 1 **pre-spawn**
+ (`spawn_daemon` asserted not called), typed error, not a traceback.
+- Post-spawn safety: with `spawn_daemon` mocked to succeed and
+ `OutputSchema.model_validate` / `render_env` / the `--output-var` encode each
+ patched to raise in turn, teardown is still invoked exactly once and the exit
+ code is 4. Explicitly including a **malformed success payload** (no
+ `session_dir` key, or a non-string one): teardown must still stop the daemon,
+ which is only possible because the path was minted pre-spawn.
+- Teardown is not skippable: with signal restoration patched to raise, teardown
+ still runs. With the stop primitive patched to raise, the child's exit code
+ (7) is still what `run` exits with, and the diagnostic lands on stderr.
+- Silent teardown: `_teardown_run` writes nothing to stdout on success, and its
+ failure diagnostic goes to stderr.
+- `stop` regression: `stop_command` stdout is byte-identical to today for each
+ outcome — `{"stopped": true}`, `{"stopped": true, "forced": true}`,
+ `{"stopped": false, "reason": "not found"}`, `"identity mismatch"`,
+ `"identity check unavailable"`, `"identity changed during grace"`.
+- Regression: `run USER@HOST -- CMD` with no new flags produces a
+ byte-identical child env.
+
+Integration (`tests/integration/`, whose compose already provides `sshd-a/b/c`,
+`sshd-bastion`, `http-target-1/2` and `fake-apiserver`):
+
+- `run --input-env X --output-var Y -- ` against one sshd
+ node: child sees a valid `OutputSchema` and its endpoints are live. Same with
+ two sshd nodes: `connections` has both keys, no `TUNSTRAP_*` scalars, both
+ endpoints reachable.
+- Teardown: after exit, `session_dir` is gone and no daemon remains (mirrors
+ `2026-06-25-cli-run-modes-design.md:238-240`); `-- sh -c 'exit 7'` → exit 7.
+- **Stdout purity — `run`'s stdout is byte-for-byte the child's stdout**, with
+ the child emitting a known sentinel, asserted across all five outcomes:
+ (a) success; (b) child exit 7; (c) graceful stop (daemon exits within
+ `--grace-seconds`); (d) forced stop (daemon ignores SIGTERM → SIGKILL path,
+ `cli.py:427-437`); (e) teardown identity error (`tunnel-data` identity
+ tampered so `verify_session` returns `mismatch`). Today's tests
+ (`tests/integration/test_cli_modes.py:127-197`) assert only return codes and
+ cleanup, which is exactly why this defect was unguarded.
+- Shim behaviour without tofu: the shim + a fake `tofu`, asserting pass-through
+ when the variable is unset, pass-through for `init`/`-version`, tunnel +
+ `TF_VAR_*` injection otherwise, `KUBECONFIG` absent from the child env, and
+ clean stdout in every branch.
+
+### The `e2e` tier — real Kubernetes through the tunnel
+
+**Why it must exist.** The current rig's `fake-apiserver` /
+`fake-apiserver-nosan` are `openssl req -x509` TLS listeners that accept a
+handshake and immediately close (`tests/integration/docker-compose.yml`). They
+prove SAN probing and kubeconfig patching and nothing more. **No test anywhere
+proves that a real Kubernetes client — let alone OpenTofu's `kubernetes` and
+`helm` providers — works through a tunstrap tunnel**, which is this design's
+central value claim. This tier would have caught the iteration-2
+`materialize: false` → `path: null` → empty `config_path` defect by execution
+rather than by inspection.
+
+Everything in this subsection labelled *[measured 2026-07-31]* was executed
+end-to-end on this workstation (Docker 28.1.1, kind 0.30.0, `kindest/node`
+v1.34.0, OpenTofu v1.12.5, kubectl v1.28.1, tunstrap 0.0.4). Items labelled
+*[designed, unverified]* could not be executed and say why.
+
+#### Topology
+
+kind creates its own Docker bridge network named `kind`; the control-plane
+container joins it and publishes `6443` on a **random** host port
+(`127.0.0.1:45491->6443/tcp` in the probe run), so the host-side kubeconfig is
+useless to a container. Measured reachability:
+
+| From | To | Result |
+|---|---|---|
+| container on network `kind` | `https://-control-plane:6443/version` | HTTP 200 |
+| container on network `kind` | `https://172.18.0.2:6443/version` | HTTP 200 |
+| container on the default bridge | `https://172.18.0.2:6443/version` | connect failure |
+
+So the SSH node container **must** join the `kind` network. This mirrors
+production faithfully: tunstrap SSHes to a host that can reach the API server,
+and forwards to it.
+
+Compose changes — a new `tests/e2e/docker-compose.yml` (not an edit to the
+integration one, see Constraints below) with a single `sshd-kube` service that
+declares the kind network as **external**:
+
+```yaml
+services:
+ sshd-kube:
+ image: lscr.io/linuxserver/openssh-server:latest
+ environment: [PUID=1000, PGID=1000, USER_NAME=tester,
+ PUBLIC_KEY_FILE=/keys/id_test.pub,
+ SUDO_ACCESS=false, PASSWORD_ACCESS=false]
+ volumes:
+ - ./_keys:/keys:ro # generated by the e2e fixture, NOT shared
+ - ./_sshd_conf:/config/sshd/sshd_config.d:ro # REQUIRED, see below
+ - ./_kube:/etc/kube:ro # the node's kubeconfig, fixture-populated
+ ports: ["127.0.0.1::2222"]
+ networks: [kind]
+networks:
+ kind:
+ external: true # created by `kind create cluster`
+```
+
+**The `sshd_config.d` mount is mandatory, not cosmetic.** In
+`lscr.io/linuxserver/openssh-server:latest` as pulled 2026-07-31, the shipped
+`/config/sshd/sshd_config` sets `AllowTcpForwarding no` at line 92, and the image
+emits an `Include /config/sshd/sshd_config.d/*.conf` line **only when that
+directory exists**. Because OpenSSH takes the first obtained value for a keyword,
+the mounted `allow_tcpfwd.conf` (`AllowTcpForwarding yes`) wins over line 92 —
+but only if the directory is mounted. Without it the server refuses every
+forwarded connection with `administratively prohibited`, while the tunnel still
+appears to start cleanly [measured 2026-07-31 — see "Why the `sshd_config.d`
+mount matters" below].
+
+#### The remote kubeconfig
+
+The fixture copies the control-plane's **in-node** kubeconfig
+(`/etc/kubernetes/admin.conf`, obtained with `docker cp` / `docker exec cat`)
+into `tests/e2e/_kube/admin.conf`, which the compose file mounts read-only at
+`/etc/kube/admin.conf` inside `sshd-kube`. That path is what `kube_targets`
+reads over SSH, exactly as the consumer reads `/etc/rancher/k3s/k3s.yaml`.
+
+Measured properties of that file, all of which the flow depends on:
+
+- `server: https://-control-plane:6443` — a DNS name, resolvable on the
+ `kind` network. `kube.py:295` parses this to decide the forward target, so the
+ forward lands on the right host without any extra configuration.
+- It carries embedded `certificate-authority-data`, `client-certificate-data`
+ and `client-key-data` — the shape `parse_kubeconfig` expects.
+- The apiserver certificate SANs are
+ `DNS:kubernetes, kubernetes.default, kubernetes.default.svc,
+ kubernetes.default.svc.cluster.local, localhost, -control-plane` and
+ `IP:10.96.0.1, 172.18.0.2, 127.0.0.1`.
+
+Because the original host (`-control-plane`) **is** in the DNS SAN list,
+`choose_tls_server_name` (`kube.py:177-196`) returns it as an exact match with
+`fellback=False`, so the tier exercises the **clean, warning-free** path rather
+than a fallback. Measured result of `tunstrap start` against this fixture:
+
+```
+endpoint : https://127.0.0.1:37141
+tls_server_name : -control-plane
+materialized : /tunnel-data/node-k3s
+warnings : []
+```
+
+and `kubectl --kubeconfig get nodes` returned the control-plane
+node `Ready` [measured 2026-07-31]. Cluster naming must be deterministic
+(`kind create cluster --name tunstrap-e2e` → container
+`tunstrap-e2e-control-plane`) because that name is both the forward target and
+the expected `tls_server_name`.
+
+#### Fixture layout
+
+```
+tests/e2e/
+ __init__.py
+ conftest.py # kind + compose lifecycle, session-scoped
+ docker-compose.yml
+ _keys/ # GENERATED, gitignored: id_test, id_test.pub
+ _sshd_conf/ # TRACKED: allow_tcpfwd.conf ("AllowTcpForwarding yes")
+ _kube/ # GENERATED, gitignored: admin.conf
+ shim/tofu-tunstrap-novar # TRACKED: --output-var negative control (test-only)
+ module/
+ versions.tf # kubernetes + helm provider constraints
+ main.tf # var.tunstrap -> try(jsondecode) -> config_path
+ charts/probe/
+ Chart.yaml # apiVersion: v2, name: probe, version: 0.1.0
+ templates/configmap.yaml # one ConfigMap: data.proof = "through-the-tunnel"
+ test_tofu_providers.py
+ test_shim.py
+```
+
+**The tier must be self-contained; it cannot borrow the integration rig's
+fixtures.** `tests/integration/_keys/` is gitignored (`.gitignore:42`) and
+untracked (`git ls-files tests/integration/_keys/` → empty); the keypair exists
+only as a side effect of the `ssh_keypair` fixture in
+`tests/integration/conftest.py:24-57`, which pytest loads **only** for tests
+under `tests/integration/`. A `pytest tests/e2e` run in a clean checkout would
+therefore mount a non-existent directory. Hence: the e2e fixture generates its
+**own** Ed25519 keypair into `tests/e2e/_keys/` (same `cryptography`-based
+approach as the integration fixture, which avoids a paramiko dependency), and
+`_sshd_conf/allow_tcpfwd.conf` is **committed** rather than referenced across
+suites. No cross-suite coupling in either direction — which also keeps the
+"do not break the existing rig" constraint literally true.
+
+`.gitignore` gains `tests/e2e/_keys/` and `tests/e2e/_kube/`.
+
+#### Session fixture lifecycle
+
+One session-scoped fixture, yielding a dict of connection facts. Sequence:
+
+1. **Preflight.** Skip the whole tier with a clear reason if `kind`, `tofu` or
+ `docker` is absent — a missing tool must not look like a product failure.
+2. **Keys.** Generate `_keys/id_test` (0600) + `id_test.pub` if absent.
+3. **Cluster.** `kind create cluster --name tunstrap-e2e --wait 90s`. The name is
+ fixed because the control-plane container name (`tunstrap-e2e-control-plane`)
+ is both the SSH forward target and the expected `tls_server_name`. Delete any
+ pre-existing cluster of that name first, so a crashed prior run cannot leave a
+ half-configured cluster that silently changes results.
+4. **Kubeconfig.** `docker exec tunstrap-e2e-control-plane cat
+ /etc/kubernetes/admin.conf` → `_kube/admin.conf` (0644 — it is read over SSH
+ by the `tester` user).
+5. **Compose.** `docker compose up -d --wait`, which joins the external `kind`
+ network. Then **SSH readiness**: poll an actual authenticated SSH command
+ (not a TCP connect — the listener accepts before the key is installed) until
+ it succeeds or a timeout expires. Then discover the **dynamic** host port with
+ `docker compose port sshd-kube 2222`, exactly as the integration conftest does
+ (`conftest.py:91-99`); the published port is random by design.
+6. **Yield** `{host, port, private_pem, cluster_name, kubeconfig_in_node_path,
+ module_dir, shim_path}`.
+7. **Teardown**, unconditional and in reverse: `docker compose down -v`, then
+ `kind delete cluster --name tunstrap-e2e`, then remove `_kube/`. Cluster
+ deletion must run even if compose teardown fails.
+
+**Per-test isolation.** Each test gets its own copy of `module/` in a `tmp_path`,
+with `TF_DATA_DIR` and the state file inside it, so tests cannot share
+`.terraform/` or `terraform.tfstate` and cannot pass because of a neighbour's
+leftovers. Provider *downloads* are shared via a session-scoped
+`TF_PLUGIN_CACHE_DIR`, so isolation costs one `init` per test but not one
+download per test.
+
+`module/main.tf` is the **exact chain this spec designs**, not a hand-written
+kubeconfig path:
+
+```hcl
+variable "tunstrap" {
+ type = string
+ default = ""
+ sensitive = true
+}
+
+locals {
+ tunnel = try(jsondecode(var.tunstrap), { connections = {} })
+ kubepath = try(local.tunnel.connections.node.kube_targets.k3s.path, "")
+}
+
+provider "kubernetes" {
+ config_path = local.kubepath != "" ? local.kubepath : null
+ host = local.kubepath == "" ? "https://127.0.0.1:0" : null
+ cluster_ca_certificate = local.kubepath == "" ? "" : null
+ client_certificate = local.kubepath == "" ? "" : null
+ client_key = local.kubepath == "" ? "" : null
+}
+
+provider "helm" {
+ kubernetes {
+ # same five lines
+ }
+}
+
+resource "kubernetes_namespace" "probe" {
+ metadata { name = "tunstrap-e2e" }
+}
+
+resource "helm_release" "probe" {
+ name = "probe"
+ chart = "${path.module}/charts/probe"
+ namespace = kubernetes_namespace.probe.metadata[0].name
+}
+```
+
+The inert branch is deliberately identical in shape to the consumer's
+(`terragrunt.hcl:35-47`), so this module is also a regression test for the
+provider configuration the recipe tells consumers to write.
+
+**Pin the providers.** `>= 2.30.0` resolved to `hashicorp/kubernetes` **v3.2.1**,
+which emits `Deprecated; use kubernetes_namespace_v1` for
+`kubernetes_namespace` [measured 2026-07-31]. Pin `~> 2.30` (matching the
+consumer's `versions.tf:9-33`) or use the `_v1` resource names, so a provider
+major bump cannot turn this tier red for a reason unrelated to tunnelling.
+
+#### Assertions
+
+Every assertion below states **how it fails when its target is broken**. An
+assertion with no such mechanism is decorative and does not belong here.
+
+| # | Assertion | Fails when broken because… | Status |
+|---|---|---|---|
+| 1 | `tofu apply` creates Namespace `tunstrap-e2e`, read back through the API | no tunnel ⇒ provider cannot reach any apiserver ⇒ apply errors | *[measured — "Apply complete! Resources: 2 added"]* |
+| 2 | `helm_release` creates ConfigMap `probe-cm` with `data.proof=through-the-tunnel` | value is compared, not just existence; a stale/foreign object fails the compare | *[measured]* |
+| 3 | Helm release recorded in-cluster as Secret `sh.helm.release.v1.probe.v1` | absent if the provider only rendered locally without reaching the cluster | *[measured]* |
+| 4 | **Chain integrity** (see below) | KUBECONFIG cleared + negative control + `kubepath` output compare | *[measured in part — see "On assertion 4"]* |
+| 5 | `tofu destroy` removes both; Namespace lookup returns `NotFound` | a no-op destroy leaves the Namespace present and the lookup succeeds | *[measured — "Destroy complete! Resources: 2 destroyed"]* |
+| 6 | Inert branch: `TF_VAR_tunstrap` unset ⇒ `tofu plan` succeeds | if `try()` were dropped to bare `jsondecode`, `jsondecode("")` errors and plan fails | *[measured]* |
+| 7 | **`init` pass-through** (see below) | poisoned `TUNSTRAP_INPUT` makes any accidental tunnel abort before tofu starts | *[measured in part]* |
+| 8 | **Child exit-code propagation** (see below) | sentinel proves the child ran; exit code 42 is outside tunstrap's reserved set | *[designed, unverified]* |
+| 9 | **stdout purity** (see below) | byte-equality against the same child run without the shim | *[designed, unverified — the leak it guards is reproduced live below]* |
+| 10 | Real provider failure still surfaces: apply against a stopped cluster exits non-zero | distinct from 8; covers the error path 8 deliberately excludes | *[designed, unverified]* |
+
+**On assertion 4 — chain integrity.** The tier's whole purpose is to prove
+providers are configured by `--output-var` → `TF_VAR_tunstrap` →
+`try(jsondecode(...))` → `config_path`. Three mutually reinforcing checks,
+because the naive version can pass for the wrong reason:
+
+- **KUBECONFIG is cleared** by the shim (`env -u KUBECONFIG`, above), and the
+ test asserts it: a `tofu` wrapper on `PATH` dumps its environment, and the test
+ requires `KUBECONFIG` to be **absent** and `TF_VAR_tunstrap` **present**.
+ Without this, a broken chain would silently succeed via the injected
+ `KUBECONFIG`, which points at the very same materialized file.
+- **Negative control.** The identical apply with `TF_VAR_tunstrap` **unset** but
+ everything else unchanged (tunnel up, `run` still injecting its env) must
+ **fail**. If it succeeds, some other path is reaching the cluster and the
+ positive result proves nothing. This is the single most valuable assertion in
+ the tier.
+- **Value compare.** `module/` exposes `output "kubepath_used" { value =
+ local.kubepath }`; the test asserts it equals
+ `connections.node.kube_targets.k3s.path` from the envelope. A hard-coded or
+ fallback path fails the compare.
+
+**On assertion 7 — `init` pass-through.** Checking that no session directory
+exists *after* `init` returns cannot distinguish "never tunnelled" from
+"tunnelled and torn down correctly". Make the wrong path fail loudly instead:
+run `init` with `TUNSTRAP_INPUT` set to a **deliberately invalid, non-empty**
+value (e.g. `{invalid`). Pass-through never reads it, so `init` succeeds and the
+`tofu` wrapper records that it ran. Any accidental `tunstrap run` exits **1**
+(`SchemaValidationError`, pre-spawn) and tofu never launches. Assert exit 0 **and**
+the wrapper's execution sentinel — the two together are unambiguous.
+
+**On assertion 8 — child exit code.** Asserting merely "non-zero" proves nothing:
+tunnel failure (2/3/4), usage error (64) and provider failure are all non-zero
+and most do not involve the child running at all. So: a fake `tofu` on `PATH`
+prints a fixed sentinel to stdout and exits **42** — outside tunstrap's reserved
+set (1, 2, 3, 4, 64) and distinct from the launch-failure code 127. Assert the
+exact code 42 **and** the sentinel. Real provider failures are covered separately
+by assertion 10, so this assertion is not weakened to accommodate them.
+
+**On assertion 9 — stdout purity.** Asserting merely that the known
+`{"stopped": …}` line is absent would pass while some *other* tunstrap message
+contaminated the stream. The oracle is byte equality: the same deterministic
+fake `tofu` (fixed sentinel bytes, no timestamps, no random ordering) is run
+twice — once directly, once through the shim — and the two captured stdouts must
+be **byte-for-byte identical**. Any injected byte, from any source, fails.
+
+**What "measured in part" means for assertions 7-9.** The designed shim calls
+`tunstrap run --input-env ... --output-var ...`, and those flags do not exist in
+0.0.4, so the real shim could not be executed. What was executed is a
+**branch-for-branch simulation** using today's `start`/`stop`, with identical
+dispatch:
+
+```
+$ shim init → PASSTHRU(init/version) init … (no session created)
+$ shim apply → TUNNEL apply … session_created=yes
+ Apply complete! Resources: 2 added
+```
+
+So the *dispatch logic* and the *init-skip* are measured; the exact flag surface
+is not, and becomes verifiable the moment Additions 1–2 land.
+
+The stdout defect assertion 9 guards is not hypothetical. Under today's code:
+
+```
+$ tunstrap run … -- sh -c 'echo CHILD_STDOUT_SENTINEL'
+1 CHILD_STDOUT_SENTINEL
+2 {"stopped": false, "reason": "identity changed during grace"}
+```
+
+[measured 2026-07-31] — line 2 is `_kill_with_identity` writing to stdout, and
+under the shim it would land inside tofu's output stream. This tier is where
+that assertion runs against real tofu output, complementing the unit-level
+checks.
+
+#### Marker, CI job, and blast radius
+
+**Own marker `e2e`, own CI job.** `pyproject.toml` gets
+`markers = [..., "e2e: requires kind + tofu (real cluster)"]` and `addopts`
+becomes `-m 'not integration and not e2e'`. Rationale: the existing
+`integration` marker's contract is "docker compose alone", and this tier needs
+kind, `tofu`, a 1.45 GB node image and an external Docker network. Folding it
+into `integration` would (a) break that contract, (b) make every integration run
+pay ~90 s of cluster setup, and (c) couple a fast, always-run suite to a slow
+one. A separate job also lets it be `continue-on-error` or nightly if it proves
+flaky, without weakening the required checks.
+
+- **`addopts` effect:** today `-m 'not integration'` already excludes unmarked-as-
+ integration tests; adding `and not e2e` keeps `pytest` (bare) and the unit job
+ unchanged. **Unit tests still run on macOS untouched** — nothing in this tier
+ is imported by `tests/unit/`.
+- **Coverage combine:** the `coverage` job currently downloads exactly two
+ artifacts and runs `coverage combine` + `--fail-under=80`. Simplest correct
+ choice: **the `e2e` job does not produce coverage data** and is not added to
+ the combine step. It exercises tofu and a cluster, not new tunstrap lines
+ beyond what the integration suite already covers, and adding a third artifact
+ would make the gate depend on the slowest, most environment-sensitive job.
+- **Existing rig untouched:** new directory `tests/e2e/`, new compose file. The
+ `integration` marker stays runnable with docker compose alone.
+
+#### Cost
+
+Measured on this workstation (10 cores, 31 GB, Docker 28.1.1):
+
+| Step | Time |
+|---|---|
+| `kindest/node` image pull (1.45 GB, cold) | ~28 s |
+| `kind create cluster --wait` (image cached) | 36 s |
+| `kind create cluster` including the pull | 64 s |
+| `sshd-kube` container ready | ~14 s |
+| `tunstrap start` (SSH + SAN probe + materialize) | ~2–3 s |
+| `tofu init`, first time (downloads `kubernetes` + `helm`) | 8.8 s |
+| `tofu init`, per test with `TF_PLUGIN_CACHE_DIR` warm | ~1–2 s |
+| `tofu apply` (2 resources) | 1.3 s |
+| `tofu destroy` | 7.6 s |
+| `kind delete cluster` | ~5 s |
+
+Per-test module isolation (see "Session fixture lifecycle") multiplies only the
+warm `init`, not the download, so a handful of tests adds seconds rather than
+minutes.
+
+**Realistic total: ~2–2.5 minutes of work**, plus GitHub Actions job overhead
+(checkout, Python, `pip install -e ".[dev]"`, installing kind and tofu) — call it
+**4–5 minutes wall clock** for the job. That is cheap enough to run per-PR and is
+why this is worth automating.
+
+**`tofu init` needs network** to fetch providers from
+`registry.opentofu.org`; it cannot be avoided. Options: accept the 8.8 s
+(recommended — it is small next to cluster setup), or cache
+`~/.terraform.d/plugin-cache` keyed on `versions.tf` via `actions/cache` and set
+`TF_PLUGIN_CACHE_DIR`. Note this is the *runner's* network, unrelated to the
+tunnel, and unrelated to the shim's `init`-skip decision.
+
+#### Why the `sshd_config.d` mount matters — and why the existing rig is fine
+
+**The existing integration suite is green.** Measured 2026-07-31:
+`pytest tests/integration -m integration -q` → **`29 passed in 78.53s`**. Nothing
+in this subsection reports a defect in the current rig; it explains a fixture
+constraint the new `e2e` tier must respect.
+
+Two independent facts about the rig, both measured, which are easy to conflate:
+
+1. **Forwarding is administratively disabled on `sshd-a/b/c`.** They have no
+ `sshd_config.d` mount, and in `lscr.io/linuxserver/openssh-server:latest` as
+ pulled 2026-07-31 the shipped config sets `AllowTcpForwarding no` (line 92).
+ The image emits `Include /config/sshd/sshd_config.d/*.conf` **only when that
+ directory exists**, so only `sshd-bastion` gets the override. Effective
+ configuration, read from the running containers with
+ `sshd.pam -T -f /config/sshd/sshd_config -h …`:
+
+ ```
+ sshd-a allowtcpforwarding no
+ sshd-bastion allowtcpforwarding yes
+ ```
+
+ and a forward through `sshd-a` to `127.0.0.1:2222` — a target inside the
+ container itself, needing no cross-network path — is refused by the server:
+ `channel 2: open failed: administratively prohibited: open failed`.
+
+2. **`sshd-a/b/c` are not on the `internal` network.** They declare no
+ `networks:` block (`docker-compose.yml:2-16`), so they sit on
+ `integration_default` only, while `target-1`/`target-2` are aliased solely on
+ `internal`. `sshd-bastion` is the only service on both. So `sshd-a` has no
+ route to `target-1` **independently** of fact 1 — being the only cross-network
+ host is precisely the bastion's purpose, and what the cross-host forwarding
+ tests exercise.
+
+An earlier draft of this spec attributed a single probe (`sshd-a` → `target-1`
+returning empty) wholly to fact 1 and extrapolated that the suite must be red.
+That extrapolation was wrong: **both** facts applied to that probe, and the suite
+is green because **no test moves data through an `sshd-a` forward**. The `sshd-a`
+tests are SFTP-only (`test_fetch_files.py`, `test_fetch_security.py`) or assert
+port *allocation* without traffic (`test_multiport.py:20-47` checks only that two
+forwards get distinct local ports; `test_start.py:30-54` checks `connections`
+keys and `pid`). Every test that actually moves bytes through a forward uses the
+bastion — including `test_kube_targets.py:70` and `:121`, which pass
+`ssh_test_cluster["bastion_port"]`.
+
+**Consequence for the `e2e` tier — this is the part that matters.** The new
+`sshd-kube` service *does* move data through a forward, so it **must** mount
+`_bastion_sshd_config` at `/config/sshd/sshd_config.d`, exactly as the compose
+snippet above shows. Omitting it produces a tunnel that opens cleanly and then
+refuses every connection.
+
+Two notes, neither a defect and both **out of scope**:
+
+- The image tag is unpinned (`:latest`). That is genuine supply-chain fragility
+ — an upstream change to the shipped `sshd_config` would alter fixture
+ behaviour with no repo change — and pinning a digest would be cheap insurance.
+- `tunstrap start` returns success when a forward target is later unroutable or
+ administratively refused. This is **not a bug**: SSH `direct-tcpip` failures
+ surface per *connection*, not at listener-setup time, so a local listener can
+ only ever be optimistic. A startup reachability probe (open and immediately
+ close one channel per target, downgrading a failure to a `TunnelWarning`) is a
+ plausible **enhancement** — it would have turned the fixture hazard above into
+ an immediate diagnostic — but it changes `start`'s contract and cost, and
+ belongs in its own proposal rather than here.
+
+### Verification gates
+
+From `.github/workflows/test.yml`: `black --check .`, `ruff format --check .`,
+`ruff check .`, `pylint tunstrap/` (`fail-under = 9.0`, `pyproject.toml`),
+`vulture tunstrap/ vulture_whitelist.py`, `mypy --strict tunstrap`,
+`pytest tests/unit` on {ubuntu, macos} × {3.10–3.13}, `pytest tests/integration
+-m integration`, and combined `coverage report --fail-under=80`.
+
+Added by this spec: a **separate `e2e` job** (ubuntu only) running
+`pytest tests/e2e -m e2e`, which installs kind and `tofu` and creates a real
+cluster. It is **not** part of the coverage combine (see "Marker, CI job, and
+blast radius"), and it does not alter the unit or integration jobs.
+
+**Pre-existing breakage found here, resolved during implementation.** When this
+spec was written `pyproject.toml` pinned only `ruff>=0.8`, so a fresh
+`pip install -e ".[dev]"` resolved ruff **0.16.1**, against which
+[measured 2026-07-31] `ruff check .` reported **59 errors** in `.py` sources and
+tests (19 `I001`, 8 `PLW1510`, 7 `UP037`, 6 `RUF100`, 4 `UP035`, …) and
+`ruff format --check .` wanted **6 files** — *all Markdown*, because ruff 0.16
+formats Python code fences inside `.md`. `black --check .` was clean; ruff
+**0.15.18** was clean on both gates. So black and `ruff format` had **not**
+diverged on Python — zero `.py` files differed; the gate broke on new 0.16 lint
+rules plus Markdown formatting.
+
+Fixed as the diagnosis predicted — a version pin plus an exclude: `pyproject.toml`
+now pins `ruff>=0.16,<0.17` (an upper bound, because the formatter's behaviour
+moves between minors and the file counts are gate values), and
+`[tool.ruff.format] exclude = ["docs/**/*.md"]` keeps ruff out of prose. All
+lint findings were fixed or explicitly suppressed. Both gates are green.
+
+## HCL consumer impact
+
+**Before** (`consumer-repo/garuda/`): `terragrunt.hcl:11-14` (`mktemp -u` marker),
+`:55-83` (28-line `tunnel_up_script` with `jq`, `umask 077`, `unset KUBECONFIG`,
+mock short-circuit, `uvx ... tunstrap start`), `:221-237` (`after_hook` with a
+second inline bash + `jq` + `tunstrap stop`), `:250-306` (`run_cmd` passing the
+whole `InputSchema` as argv), `locals.tf:72`
+(`jsondecode(var.tunnel_path == "" ? ... : file(var.tunnel_path))`), and
+`variables.tf:273` (`variable "tunnel_path"`).
+
+**After.** `root.hcl:5` already reads `terraform_binary = "tofu"`, so this is a
+one-line edit, not a new attribute: `terraform_binary =
+"${get_repo_root()}/bin/tofu-tunstrap"`. Unit `terragrunt.hcl`:
+
+```hcl
+terraform {
+ source = "."
+
+ extra_arguments "tunstrap" {
+ commands = ["plan", "apply", "destroy", "refresh", "import"]
+ arguments = []
+
+ # dependency.* resolves here [measured 2026-07-31]; it does NOT
+ # resolve in `locals`, so the conditional must be inline.
+ env_vars = dependency.infra.outputs.connection_data_hub.host != "0.0.0.0" ? {
+ TUNSTRAP_INPUT = jsonencode({
+ nodes = merge(
+ {
+ hub = {
+ host = dependency.infra.outputs.connection_data_hub.host
+ port = 22
+ user = dependency.infra.outputs.connection_data_hub.user
+ ssh_pkey = dependency.infra.outputs.connection_data_hub.ssh_private_key
+ remote_targets = { k3s = "127.0.0.1:6443" }
+ kube_targets = { k3s = { kubeconfig_path = "/etc/rancher/k3s/k3s.yaml" } }
+ required = true
+ }
+ },
+ {
+ for k, cd in dependency.infra.outputs.connection_data_edges :
+ k => {
+ host = cd.host
+ port = 22
+ user = cd.user
+ ssh_pkey = cd.ssh_private_key
+ remote_targets = { k3s = "127.0.0.1:6443" }
+ kube_targets = { k3s = { kubeconfig_path = "/etc/rancher/k3s/k3s.yaml" } }
+ required = true
+ }
+ },
+ )
+ daemon = {
+ shutdown_grace_seconds = 10
+ materialize = true
+ # auto_stop_idle_seconds is intentionally absent: the daemon's
+ # lifetime is now exactly the tofu child's.
+ }
+ })
+ } : {}
+ }
+}
+```
+
+That `nodes`/`daemon` body is the existing payload from
+`terragrunt.hcl:257-304`, moved verbatim except for the dropped
+`auto_stop_idle_seconds`.
+
+`locals.tf:72` becomes:
+
+```hcl
+tunnel = try(jsondecode(var.tunstrap), { connections = {} })
+```
+
+with `variable "tunstrap" { type = string, default = "" }` replacing
+`tunnel_path` (`variables.tf:273`). **The `try()` is required, not stylistic**:
+the default is `""`, and bare `jsondecode("")` fails, so plain
+`jsondecode(var.tunstrap)` would break every command that gets no tunnel — the
+exact case the next paragraph requires the module to tolerate. The two read sites
+are unchanged in shape: `locals.tf:76` and `locals.tf:79` still take
+`connections[].kube_targets.k3s.path` — the comment at `locals.tf:65`
+("Consumer reads only the path string") stays true.
+
+### Which tofu commands need a tunnel
+
+The `commands` list is a deliberate enumeration, not a copy of the old
+`after_hook` list (`terragrunt.hcl:222`). The old list existed because the tunnel
+was started during `inputs` evaluation and therefore had to be torn down for
+*any* command that evaluated `inputs`; the new list answers a different question
+— which commands actually make provider API calls.
+
+| Command | Tunnel | Why |
+|---|---|---|
+| `plan`, `apply`, `destroy`, `refresh` | **yes** | `kubernetes`/`helm` providers read and write live cluster state |
+| `import` | **yes** | reads the live resource to populate state; omitting it is a silent trap |
+| `console` | **yes**, if used | can evaluate provider data sources; add it if the consumer uses it interactively |
+| `init` | no | backend config + provider downloads only; the state backend is a public S3-compatible endpoint (`root.hcl:19-44`) reached directly rather than through the tunnel, and providers come from the public registry (`garuda/versions.tf:9-33`) |
+| `validate` | no | schema/expression checks only; no provider calls. It *was* in the old `after_hook` list solely because it evaluated `inputs` |
+| `output`, `show`, `state *`, `taint`, `untaint`, `fmt`, `providers` | no | read or rewrite state and files; no cluster contact |
+
+Everything not listed in `commands` gets `TUNSTRAP_INPUT` unset and takes the
+shim's pass-through branch, so the failure mode of forgetting a command is a
+**provider error against the inert loopback endpoint**, not a silent wrong
+result. `import` is called out explicitly because it is the one state-mutating
+command that is easy to leave off the list.
+
+**Deleted from the consumer:** the `run_cmd` block (`:250-306`), the `mktemp`
+marker local (`:11-14`), the `tunnel_up_script` heredoc (`:55-83`), the
+`after_hook` (`:221-237`), both `jq` invocations, the `umask 077`, and the
+`InputSchema` in argv. The `auto_stop_idle_seconds = 7200` workaround (`:299`)
+can shrink or go away — the daemon's lifetime is now the child's.
+**`session_dir` has no Terraform consumer**: it appears in `.tf` only inside a
+comment (`locals.tf:64`), and its sole functional use is the down-hook shell at
+`terragrunt.hcl:229`, which this design deletes.
+
+**`terragrunt output` gets no tunnel — deliberately.** `output` is not in
+`commands`, so `TUNSTRAP_INPUT` is unset for it and its auto-`init` [measured
+2026-07-31, fact 4]; it does not need one, since `tofu output` reads state.
+**Consequence: the module must tolerate an unset `tunstrap` variable** — and it
+already does. `locals.tf:69-72` has the empty-string branch, and
+`terragrunt.hcl:35-47` defines the inert provider body substituted into the
+generated `providers.tf` at `:129`, `:137`, `:144`, `:151`, pinning
+`host = "https://127.0.0.1:0"` with empty cert material so the providers cannot
+fall back to `$KUBECONFIG`. That branch now serves three cases instead of two:
+`tofu test`, mock state, and non-tunneled commands.
+
+**Security, stated honestly.** Private keys still travel in the child's
+**environment** — but they already do today, as `TF_VAR_connection_data_hub`
+[measured 2026-07-31, fact 2]. What this design removes is the *command line*:
+`ProcessExecutionError` can no longer print a PEM. The stronger follow-on is the
+already-shipped ssh-agent fallback (`schemas.py:272-283`,
+`docs/specs/2026-06-25-ssh-agent-fallback-design.md`): if the consumer exports
+`SSH_AUTH_SOCK` and drops `ssh_pkey` from the payload, key material leaves the
+payload entirely. Recommend it in the recipe; it is not required by this design.
+
+## Documentation deliverables
+
+- **`docs/recipe_terragrunt.md`** (new) — the canonical recipe: the shim
+ verbatim, the before/after HCL, and the ssh-agent recommendation. It must carry
+ the measured facts a future agent would otherwise re-derive — path-only
+ `terraform_binary`; env-var scoping including the auto-`init` behaviour; the
+ invocation counts; `dependency.*` in `extra_arguments.env_vars` but not in
+ `locals`; `TF_VAR_*` JSON inputs; payload fidelity at ~10 KB; and the hard rule
+ that **the shim must never write to stdout**.
+- **`README.md`** — document both flags in the `run` section (`:175-195`),
+ extend its exit-code paragraph (`:191-195`), link the recipe from "Project
+ documents" (`:518-522`), and fix three stale `token` references: `:340` (a
+ `"token": ""` line in the Output reference example), `:385` (a
+ Security-notes bullet calling `token` the authorization handle), `:429` (a
+ Troubleshooting row on "token mismatch"). `token` was removed from
+ `OutputSchema` by the 2026-06-24 design and is absent from `schemas.py:353-362`.
+ Lines `:474-485` mention `token` legitimately in a migration note — leave those.
+
+## Decision log
+
+Carried forward from the superseded spec (still binding):
+
+1. **Deterministic `--session-dir`** — rejected; `session_dir` has no Terraform
+ consumer (reconfirmed: `locals.tf:64` is a comment).
+2. **Hash-keyed session reuse** — rejected; latency-only.
+3. **Idempotent `up` verb / replace-active semantics** — rejected; breaks the
+ race-free `SessionActive` invariant from issue #7.
+4. **"Wait for zero active connections before replacing"** — rejected; idle ≠
+ unused (the consumer regressed on this at `auto_stop_idle_seconds = 300`).
+5. **Named sessions per terragrunt command** — rejected; multiplies daemons.
+6. **`--owner-ancestor N` / negative pid as ancestor depth** — rejected; fragile
+ to launcher depth, collides with `kill(2)` semantics.
+ 7. **A `--config` file with Terragrunt-output adapters inside tunstrap** —
+ rejected; layering violation. **Reaffirmed and strengthened here**: it is the
+ root principle behind putting the shim outside the package.
+ **Revised (post-land):** the *direction* of this principle is reversed for
+ the proxy specifically — see "Shipping the shim (revised)" and new item 31.
+ The `--config` adapters themselves remain rejected; what changed is that the
+ *consumer shim's logic* (pass-through, `init`/`-version` bypass, the
+ `TF_VAR_tunstrap` + `tofu` invocation) now also ships in-package as
+ `tunstrap/tofu_proxy.py`, not that tunstrap grew generic Terragrunt
+ adapters. `cli.py` stays Terraform-vocabulary-free; only `tofu_proxy.py`
+ carries it.
+8. **Matching the joined `/proc//cmdline`** — rejected; the owner pattern
+ appears in its own cmdline [measured 2026-07-30]. Moot under (15).
+9. **`re.fullmatch` / anchoring over `argv[0]`** — rejected. Moot under (15).
+10. **Owner as a synthetic `TunnelWarning`** — rejected; `TunnelWarning` means
+ "non-fatal failure on an optional node". Moot under (15) — `OutputSchema`
+ gains no fields at all here.
+11. **`--owner` on `run`** — rejected; `run` already guarantees teardown in its
+ `finally` (`cli.py:327-330`). **This is the observation the new architecture
+ generalizes.**
+12. **Split exit codes for owner failures (64 vs a new 5)** — moot under (15);
+ `5` returns to the unallocated pool (`exceptions.py:57-63`).
+13. **`SecretStr` on the three secret fields** — deferred; `spawn_daemon`
+ serializes with `model_dump_json()`, which `SecretStr` would break.
+14. **Terragrunt-based integration tests in this repo** — out of scope; needs
+ tofu + terragrunt in the test image and a state backend.
+
+New:
+
+15. **The entire `--owner` process-ownership feature** (watchdog, `owner_gone`
+ IPC kind, exit 5, pid-reuse start-time guard) — **obsolete, cancelled before
+ implementation.** Under the proxy model tunstrap *is* `tofu`'s parent and its
+ lifetime is exactly the child's: orphans become impossible by construction
+ rather than detected after the fact. Nothing is removed — none of it was ever
+ built (`grep -rn "owner" tunstrap/*.py` → only `kube.py:235,245`).
+16. **`--output-file`** — obsolete. The result goes into the child's environment
+ via `--output-var`; under the shim, stdout belongs to tofu and is never a
+ result channel [measured 2026-07-31, fact 8]. The 0600-file design solved a
+ problem that no longer exists.
+17. **`--placeholder-host`** — obsolete. `env_vars` is an ordinary HCL map, so
+ the consumer omits `TUNSTRAP_INPUT` when infra is not applied; the shim's
+ first line passes through and the module's inert branch (`locals.tf:69-72`)
+ fires. Proven end-to-end both ways [measured 2026-07-31]. Zero tunstrap code,
+ versus a new flag plus an `OutputSchema.placeholder` field.
+18. **`terraform_command_line`** — does not exist. The attribute is
+ `terraform_binary` / `--tf-path` / `TG_TF_PATH`, and it is path-only
+ [measured 2026-07-31, fact 1].
+19. **Generalizing `render_env` to multi-node** — rejected. `TUNSTRAP__*`
+ has no node dimension and same-named targets across nodes collide
+ irreducibly; `2026-06-25-cli-run-modes-design.md:245` already ruled
+ multi-node CLI input out of scope. `--output-var` serves the case with a
+ node-keyed structure, so the documented single-node contract stays intact
+ and the code gains one fewer branch.
+20. **Putting the `init` skip or the pass-through inside tunstrap** — rejected.
+ Both are Terraform knowledge (`init` is a tofu subcommand; "no input means
+ no tunnel" is a Terragrunt mock-state convention). They belong in a
+ small consumer shim. Same principle as (7).
+ **Revised (post-land):** reversed by item 31. The `init`/`-version` bypass
+ and the `TF_VAR_tunstrap` pass-through now also live in
+ `tunstrap/tofu_proxy.py` (the shipped `tunstrap_tofu` entry point). The
+ consumer-file shim remains a supported option; the principle is no longer
+ absolute, by deliberate owner decision.
+21. **Reading connection data from Terragrunt-generated tfvars files** —
+ rejected. Such files do not exist: inputs travel as `TF_VAR_*` JSON env vars
+ [measured 2026-07-31, fact 2]. Even if they did, parsing them would couple
+ tunstrap to Terragrunt internals.
+
+Added in review (iteration 2):
+
+22. **Making `run`'s CONNECTION merely `required=False`** — rejected; it does not
+ work. Click distributes post-`--` tokens over declared positionals in order,
+ so `run --input-env X -- tofu plan` binds `connection='tofu'`
+ [measured 2026-07-31, Click 8.4.2]. Alternatives weighed: a
+ `--connection` **option** (rejected — breaks the documented positional form
+ in `README.md:175-195` and its integration tests) and a **separate
+ subcommand** (rejected — duplicates the entire `run` surface to express one
+ input-source difference). Chosen: **one `args` variadic**, split after
+ parsing on the presence of `--input-env`. Flag mode is untouched, and the
+ old "CONNECTION + `--input-env`" conflict becomes structurally impossible
+ instead of an error to enforce.
+23. **Leaving `_kill_with_identity`'s stdout writes in `run`'s teardown** —
+ rejected; it is a correctness bug under the shim, not a cosmetic one.
+ `run` teardown emitted `{"stopped": ...}` on **every** outcome
+ (`cli.py:376-437`) into the stream Terragrunt parses as tofu's stdout.
+ Rejected fixes: redirecting fd 1 during teardown (fragile, hides genuine
+ child output ordering) and making `stop` silent too (breaks `stop`'s
+ documented JSON contract). Chosen: a silent `stop_session` primitive, with
+ `stop_command` rendering today's JSON byte-for-byte and `run` printing
+ nothing on success and stderr on failure.
+24. **Daemon flags (`--auto-stop-idle-seconds`, `--materialize`, `--log-file`)
+ under `--input-env`** — rejected as overrides and as silent no-ops; they are
+ **usage errors (64)**. The payload's `daemon` block is complete and
+ authoritative, so there must be exactly one place to look when a tunnel
+ misbehaves. These flags are excluded from `_conn_flags_present`
+ (`cli.py:84-93`), so without an explicit rule an implementer would have
+ invented a precedence order silently.
+25. **`run` forces `daemon.materialize = True` on an `--input-env` payload** —
+ accepted as an invariant of the verb, matching today's unconditional
+ `force_materialize=True` (`cli.py:290`). A payload saying
+ `materialize: false` would make `render_env` raise (`envrender.py:42-43`)
+ and would hand `--output-var` consumers `path: null`, giving the
+ `kubernetes`/`helm` providers an empty `config_path`. This is the only place
+ `run` mutates the supplied schema, and it is documented as such.
+26. **Copying the old `after_hook` command list into `extra_arguments.commands`**
+ — rejected. That list (`terragrunt.hcl:222`) answered "which commands
+ evaluate `inputs` and therefore need teardown", which is not the new question
+ ("which commands make provider API calls"). `init` and `validate` drop off;
+ **`import` is added**, since it reads live resources and is the easiest
+ state-mutating command to forget. Unlisted commands fail loudly against the
+ inert loopback endpoint rather than silently returning wrong results.
+
+Added for the `e2e` tier:
+
+27. **kind, over k3d / a raw k3s container / a real remote cluster** — chosen for
+ three concrete reasons, not familiarity. (a) It reproduces the *production
+ shape* of the thing under test: its in-node kubeconfig lives at a real path
+ (`/etc/kubernetes/admin.conf`) with an embedded CA and a `server:` naming a
+ host reachable only from inside the cluster network — structurally identical
+ to the consumer's `/etc/rancher/k3s/k3s.yaml`. (b) Its apiserver cert carries
+ the control-plane hostname as a DNS SAN, so `choose_tls_server_name`
+ (`kube.py:177-196`) takes the exact-match branch and the tier tests the
+ warning-free path [measured 2026-07-31]. (c) It is a single static binary
+ with no daemon. k3d was the closest alternative and would very likely work
+ identically, but it adds a k3s-vs-kubeadm difference for no gain; a raw k3s
+ container needs privileged mode and hand-rolled readiness; a real remote
+ cluster is not hermetic. **This choice is cheap to revisit** — the tier
+ depends on kind only through cluster creation and one `docker exec cat`.
+28. **A local chart directory, not a remote Helm repository** — chosen. A
+ `helm_release` needs a chart, and a two-file local chart (`Chart.yaml` +
+ one ConfigMap template) removes a network dependency, a version-drift source
+ and an availability risk from a test whose subject is the *tunnel*, not Helm.
+ Measured: it produced a real in-cluster release Secret
+ (`sh.helm.release.v1.probe.v1`), so nothing about the Helm path is stubbed.
+29. **Own `e2e` marker and CI job, rather than extending `integration`** —
+ chosen. `integration`'s contract is "docker compose alone"; this tier needs
+ kind, tofu, a 1.45 GB image and an external Docker network. Extending
+ `integration` would break that contract and add ~90 s of cluster setup to
+ every integration run. Rejected alternative: one marker with a skip-if-no-kind
+ guard — it silently degrades to a no-op in exactly the environment where the
+ tier matters. `addopts` becomes `-m 'not integration and not e2e'`; the unit
+ job and macOS are unaffected.
+30. **Keeping the `e2e` job out of the coverage combine** — chosen. The combine
+ step gates at `--fail-under=80` on merged data from exactly two artifacts;
+ adding a third from the slowest and most environment-sensitive job would make
+ the coverage gate hostage to cluster flakiness, while adding almost no
+ tunstrap lines that the integration suite does not already cover. The tier's
+ value is behavioural proof, not line coverage.
+
+Added post-land (the `tunstrap_tofu` entry point):
+
+31. **Shipping the proxy in-package as `tunstrap_tofu`, alongside the consumer
+ shim** — chosen, reversing items (7) and (20) for the proxy specifically.
+ `uv tool install` yields a stable `tunstrap_tofu` entry point (item (c) of
+ "Shipping the shim" answered), so `terraform_binary` points at a stable
+ installed path with nothing copied into the consumer's repo. The fast-path
+ cost (item (a)) is managed by `execvp`-ing `tofu` before any `cli` import
+ and resolving `__version__` lazily — measured **~25 ms end-to-end via the
+ installed entry** (~12× the ~2 ms shell shim; ~74 ms per plan) vs ~225 ms
+ naive (see the recipe). The Terraform-vocabulary objection (item (b)) is
+ the trade being deliberately taken: `init`/`-version`/`TF_VAR_tunstrap`/
+ `tofu` live in `tunstrap/tofu_proxy.py`, while `cli.py` stays generic (it
+ gains only a `suppress_kubeconfig` parameter and a `run_via_env_input`
+ entry, no Terraform names). The consumer-file shim remains supported and is
+ still what the e2e tier drives (revised again: the e2e tier now drives
+ `tunstrap_tofu`, and the consumer-file shim is retired from the recipe).
+ The `env -u KUBECONFIG` incantation becomes
+ `suppress_kubeconfig=True` inside the built child env — same property (a
+ broken `config_path` chain cannot fall back to KUBECONFIG), no child-side
+ wrapper; the property is pinned by a unit test proven red against the wrong
+ implementation. The `-chdir=DIR init` gap closes as a side-effect of
+ structural argv parsing.
+
+## Out of scope
+
+- **The pydantic `ValidationError` secret leak.** `cli.py:205-211` and
+ `cli_input.py:113-123` both put `json.loads(exc.json())` into `details`, and
+ pydantic v2 error entries include the offending `input` — so a malformed node
+ echoes `ssh_pkey`. `TunstrapError._scrub` (`exceptions.py:7-12`) drops only
+ **top-level** keys and never reaches nested `errors[].input`. This design adds
+ a **third** such site (`--input-env`), raising the priority but not changing
+ the fix, which needs its own plan.
+- **Packaging / PyPI publication**, blocked by the direct-reference `asyncssh`
+ fork (`pyproject.toml:10`). The shim decision depends on the outcome and should
+ be revisited when the OCI release contract lands.
+- **Multi-node scalar env rendering** — see decision (19).
+- **Terragrunt-level integration tests in this repo** — decision (14).
+ The new `e2e` tier is **tofu-level, not Terragrunt-level**, and the two must
+ not be confused. The `e2e` tier drives `tofu` directly with `TF_VAR_tunstrap`
+ set by a shim, proving the genuinely novel path: *providers reach a real
+ cluster through a tunstrap tunnel*. Terragrunt-level testing would additionally
+ need terragrunt in the image, a state backend, `dependency` units and mock
+ outputs, in order to re-prove only Terragrunt's own behaviour — which is
+ already captured as measured facts 1–8 and belongs in
+ `docs/recipe_terragrunt.md`, not in a test. **Still out of scope:** the
+ `extra_arguments.env_vars` wiring, the `dependency.*` resolution order, and the
+ `terraform_binary` hook itself.
+- **The ruff 0.16 gate breakage** — recorded under "Verification gates";
+ pre-existing, needs a version pin and a docs decision, not this spec.
+- **Migrating the consumer repo.** This spec specifies the tunstrap side and the
+ recipe; the `consumer-repo/garuda` edit is a consumer-repo change.
+- **Symmetric projection for fetched files.** `--output-var` projects
+ `kube_targets` (drops the kube credentials) but passes
+ `fetch_files[*].content_b64` through verbatim. The asymmetry is documented
+ and deliberate for now (see `docs/recipe_terragrunt.md`): fetched files are
+ operator-requested and `FetchedFile` has no on-disk `path`, so dropping
+ `content_b64` would lose data. The end-state is symmetry with kube targets —
+ materialize fetched files under the session dir at mode `0o600`, add a `path`
+ field to `FetchedFile`, force that materialization in `run` (as is already
+ done for kubeconfigs), and *then* drop `content_b64` from the projection.
+ That is a schema addition plus a new forced-materialization rule,
+ deliberately not done in a security fix at the tail of a 64-commit branch.
diff --git a/docs/specs/2026-08-03-run-env-io-decision-history.md b/docs/specs/2026-08-03-run-env-io-decision-history.md
new file mode 100644
index 0000000..6658f9b
--- /dev/null
+++ b/docs/specs/2026-08-03-run-env-io-decision-history.md
@@ -0,0 +1,132 @@
+# Decision history: `run` env I/O and the tofu proxy
+
+- Date: 2026-08-03
+- Supersedes: `docs/superpowers/plans/2026-07-31-run-env-io.md` and
+ `docs/superpowers/plans/2026-08-01-e2e-tier.md` (removed). Those were
+ per-task execution scaffolding for `feature/run-env-io` — step-by-step
+ instructions, predicted command output, expected test counts — none of which
+ has forward value once the branch landed, and some of which had already gone
+ stale against the tree it shipped beside (see "Why this document exists"
+ below). The full task-by-task record, if it is ever needed, is the branch's
+ git history (86 commits ending at PR #13); this document is not a
+ replacement for `git log`, it is the distillation a maintainer actually
+ needs.
+- Companion design doc (kept as-is, not superseded):
+ `docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`.
+- Companion consumer-facing doc (kept as-is): `docs/recipe_terragrunt.md` — it
+ already carries most of the measured Terragrunt facts and the shim/entry-point
+ design trade in durable, tested prose. This document does not repeat that
+ content; it points at it and adds only what is *not* documented anywhere
+ else durable.
+
+## Why this document exists
+
+The two plan documents it replaces were committed intermediary planning
+artifacts — the org rule is that these must never land in the tree. They had
+also drifted: `2026-07-31-run-env-io.md` states as a hard global constraint
+*"Do not touch the `ruff>=0.8` pin"*, while this same branch changed that pin
+to `ruff>=0.16,<0.17` (`pyproject.toml:23`, commit `e57ebd1` and later). The
+plan also recorded now-false baselines ("252 passed", "29 passed" — the
+branch's own work moved both) and a local-machine fact ("local dev interpreter
+is Python 3.14.4") that has no bearing on anyone else's checkout. None of that
+is safe to leave as the record of what was decided and why.
+
+## Provider-cache finding (not published elsewhere)
+
+**Cold `tofu init`: 7.65 s. Warm plugin cache *without* a `.terraform.lock.hcl`:
+8.17–8.27 s — slower than cold. Warm cache *with* a lock file: 0.226 s.**
+
+Measured twice independently (e2e-tier tasks 3.3 and 6.3), same shape both
+times. The dominant cost is **registry version resolution**
+(`Finding hashicorp/helm versions matching "~> 2.17"`), which reruns on every
+`init` when the module ships no lock file; the plugin cache only removes the
+provider *download*, which was never the bottleneck here. `TF_PLUGIN_CACHE_DIR`
+alone is not the win it looks like — it has to be paired with a committed
+`.terraform.lock.hcl` to actually collapse init time.
+
+This is enforced and re-stated at `tests/e2e/conftest.py`'s `tofu_plugin_cache`
+fixture docstring, which is the one place code and doc were briefly out of
+sync: an earlier version of that docstring claimed the cache alone multiplies
+only a cheap "warm init (~1-2s)", which is false and was the exact claim a
+reviewer cited to dispute this measurement before the docstring was corrected.
+Trust the measured numbers above over any restated version of "the cache helps"
+that does not name a lock file.
+
+## Design decisions, and the ones that were reversed
+
+Everything in this section has a durable home in code or in
+`docs/recipe_terragrunt.md`; the entries below are short pointers plus the one
+line of "why" a maintainer needs before opening the source, not a duplicate of
+the full reasoning.
+
+- **The tofu shim moved from a copied consumer-repo shell file to an in-package
+ console entry point (`tunstrap_tofu`, `tunstrap/tofu_proxy.py`).** This
+ consciously reverses the original design's "keep Terraform vocabulary out of
+ tunstrap" principle. Full trade, including the timing measurements that
+ forced the shell-shim's existence in the first place (shell fast path 2.1 ms;
+ bare Python 17.3 ms; Python + `import tunstrap.cli` 225 ms, ~184 ms of which
+ is the import; shipped entry point end-to-end 24.6 ms; `import tunstrap`
+ 67.3→17.5 ms after making `__version__` lazy via PEP 562, `dd62372`): see
+ `tunstrap/tofu_proxy.py`'s module docstring and
+ `docs/recipe_terragrunt.md`, "Why a console script (now)".
+- **The bypass predicate is a deny-list (`init`, `version`, no-subcommand), not
+ a cluster allow-list.** An earlier version (`2425fb6`) shipped an allow-list
+ of `{plan,apply,destroy,refresh,import,console}` and was rejected on review
+ as Critical: `TUNSTRAP_INPUT` only exists for commands the consumer
+ deliberately listed in Terragrunt's own `commands`, so an allow-list is dead
+ code except in the one case where someone opted a command in on purpose —
+ exactly the case it silently broke. `b891d0d` restored the deny-list. See
+ the `_BYPASS_COMMANDS` comment in `tunstrap/tofu_proxy.py`.
+- **`--output-var` projects through an allow-list (`RunKubeTarget`), not a
+ deny-list.** `extra="ignore"` means a field added to `KubeTargetOutput` later
+ is dropped from this channel until someone adds it here on purpose; a
+ deny-list leaks each new field by default. This is the fix for a CRITICAL
+ security defect (`083b36b`, `23d81ad`): `--output-var` previously put the
+ whole `OutputSchema` — including `client_key_data` and `content_b64` (a full
+ kubeconfig) — into a Terraform variable, which OpenTofu persists into the
+ plan file; `sensitive = true` would **not** have been enough, since it
+ suppresses rendering but leaves the value in the plan file itself. See
+ `RunKubeTarget`'s docstring in `tunstrap/schemas.py`.
+- **`fetch_files[*].content_b64` is deliberately *not* projected the same
+ way — it rides the `--output-var` channel unprojected.** The asymmetry is
+ intentional, not an oversight: kube credentials were tunstrap's own material,
+ injected unasked, with a lossless on-disk alternative (`path`) already
+ present, so dropping them cost nothing; a fetched file is opt-in *twice*
+ (`--fetch` and `--output-var`), is the operator's own content, and
+ `FetchedFile` has no `path` field, so dropping `content_b64` would be a
+ silent, unrecoverable breakage of any consumer reading it. Silently
+ discarding requested data is worse than persisting data someone asked to
+ export. See `docs/recipe_terragrunt.md`, "Fetched files are exported
+ verbatim, not projected", and the design spec's "Out of scope" section for
+ the follow-up debt this leaves (give `FetchedFile` a `path`, materialize it
+ at `0600`, then drop `content_b64`).
+- **The `--input-env` payload variable is popped from the child's environment
+ before anything is injected**, because `tofu` hands its environment to every
+ provider plugin, `external` data source and `local-exec` provisioner, and the
+ payload's `ssh_pkey` is an SSH private key. See `_build_child_env`'s
+ docstring in `tunstrap/cli.py`.
+
+## Traps for anyone editing tunstrap's own shim/recipe assets
+
+Not consumer-facing — these bit the branch itself and have no other durable
+home:
+
+- **Editing a shim file in place with an in-process edit tool relaxes its mode
+ to `0775`.** Git tracks only the owner execute bit, so it reports **no diff**
+ and `git checkout` will **not** restore it. Only an exact `st_mode & 0o777 ==
+ 0o755` test (`tests/e2e/test_shim.py`) catches this. Any future in-place shim
+ edit needs an explicit `chmod 0755` afterwards, checked, not assumed.
+- **A unit that forgets `include "root"` renders an empty `terraform_binary`
+ and falls back silently to plain `tofu`** — no error, just the module's inert
+ branch reached later. Documented for consumers in
+ `docs/recipe_terragrunt.md`, "Failure modes you will hit"; repeated here
+ because it is easy to lose sight of while editing the recipe itself, where
+ the silent-fallback shape is not obvious from the diff.
+
+## Scope note: the three pre-existing plan documents
+
+`docs/superpowers/plans/2026-05-30-kube-targets.md`,
+`2026-06-24-session-reuse-task-a.md` and `2026-06-25-cli-run-modes.md` are
+untouched by this branch and were already on `main` before it started. This
+compression pass deliberately does **not** touch them — see
+`.superpowers/sdd/artifact-compression-report.md` for the reasoning.
diff --git a/docs/specs/2026-08-07-issue15-kube-identity-decisions.md b/docs/specs/2026-08-07-issue15-kube-identity-decisions.md
new file mode 100644
index 0000000..1787d95
--- /dev/null
+++ b/docs/specs/2026-08-07-issue15-kube-identity-decisions.md
@@ -0,0 +1,1195 @@
+# Decision history: kubeconfig-as-identity delivery (issue #15)
+
+> **Redaction/repoint note (2026-08-10):** Repointed provider evidence to its
+> committed spec and described the spike notes as unpublished.
+
+- Date: 2026-08-07 (revised same day, iteration 3: entries 10-13 record the
+ unified-output-contract pivot; entry 9 is marked superseded rather than
+ rewritten — an ADR is a history, decisions get superseded in place, not
+ erased)
+- Companion design doc (kept as-is, not superseded):
+ `docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md`.
+- Companion evidence, not repeated here: untracked implementation-spike notes
+ (six prototype variants against the full unit suite) and
+ `docs/specs/2026-08-10-issue15-provider-env-precedence.md` (live-probed
+ OpenTofu provider precedence).
+- Ticket: [AlexMKX/tunstrap#15](https://github.com/AlexMKX/tunstrap/issues/15),
+ a handoff superseding most of #14.
+
+This document is one entry per decision: context, alternatives considered
+(with the spike's own measured numbers where an alternative was actually
+prototyped, not just discussed), the decision, and its consequences. It does
+not restate the design doc's contract prose — see the companion doc for that.
+
+## 1. Rename placement: standalone `rename_identities(doc, node, target)`
+
+**Context.** The materialized kubeconfig's cluster/user/context identities
+need renaming to `tunstrap--` before serialization. Three
+placements were prototyped in the spike, each as an isolated branch against
+the same `feature/run-env-io` base, each run against the full 475-test unit
+suite.
+
+**Alternatives considered** (spike findings, "Part 2"):
+
+| Placement | Diff size | Unit tests broken | Note |
+|---|---|---|---|
+| V1a — inline in `run_kube_targets`, mutating `KubeconfigView` in place before `patch_view` | +28/-0 | 0/475 | Minimal, but only testable through the SSH-orchestration fakes `run_kube_targets` needs |
+| V1b — inside `dump_kubeconfig` (optional `node`/`target` kwargs, mutates `view` as a side effect of dumping) | +35/-3 | 0/475 (kwargs kept optional; making them required — closer to a "the serializer owns identity" design — would break `test_kube_patch.py`'s three no-rename calls, a real but trivial pin) | Couples "serialize to bytes" with "mutate identity"; also the shape the ticket's own imprecise phrasing about `dump_kubeconfig` invited — see the design doc's correction note |
+| **V1c — standalone `rename_identities(doc, node, target) -> str`, no `KubeconfigView` dependency** | +42/-2 | 0/475 | **Chosen** |
+
+**Decision.** V1c. It operates on the raw parsed `dict` alone (resolves
+`current-context` itself), so it is unit-testable with a bare fixture dict —
+no `KubeconfigView`, no `run_kube_targets`, no SSH fakes required to exercise
+it in isolation. It keeps `dump_kubeconfig` a pure serializer (matches the
+design doc's correction that server-address patching is `patch_view`'s job,
+not the serializer's — extending the serializer's responsibility further in
+the opposite direction, per V1b, would be the wrong direction to move it in).
+It also matches the ticket's own proposed signature verbatim.
+
+**Consequences.** One new top-level function + `__all__` export in `kube.py`,
+one call site in `run_kube_targets` between `patch_view` and
+`dump_kubeconfig`. `KubeTargetOutput` is built from the function's return
+value (the shared new name) instead of `view.cluster_name`/`view.context_name`
+post-parse. No signature change to `dump_kubeconfig`, `patch_view`, or
+`parse_kubeconfig`.
+
+## 2. `render_kube_env` split out of `render_env`
+
+**Context.** `render_env`'s single node-count guard (`envrender.py:26-30`)
+covered three unrelated things: the node-ambiguous `TUNSTRAP__*`
+scalars, the node-ambiguous per-kube-target `TUNSTRAP__*` scalars, and
+the `KUBECONFIG` colon-joined list, which is not node-ambiguous — it is
+already a path list and `render_env` already colon-joins it.
+
+**Alternatives considered.** The only alternative discussed (not prototyped
+separately, since it is the null option) was leaving `render_env` as one
+function and adding a `multi_node: bool` escape-hatch parameter that skips the
+guard for callers that only want the kube lines. Rejected without prototyping:
+it reintroduces exactly the "one function doing two things gated by a flag"
+shape the split exists to remove, and it would make the scalar-channel
+guarantee ("still requires exactly one node") a run-time parameter instead of
+an structural property of which function you call.
+
+**Decision.** Extract `render_kube_env(output) -> dict[str, str]`, callable
+for any node count including zero and multi-node. `render_env` keeps its
+existing single-node contract unchanged and delegates its kube-line
+construction to `render_kube_env` for the single-node case. **[Editorial fix,
+iteration 6]** Single-node output is **not** byte-identical to before the
+split, as this entry originally claimed — precisely: `KUBECONFIG`'s *value*
+(the colon-joined path list) is unchanged, but the conditional cardinality
+contract (entry 3, below) this same split enables also *grows the key set*
+for the single-file case (adding `KUBE_CONFIG_PATH` alongside `KUBECONFIG`,
+which pre-split `render_env` never exported). The design doc carries the
+same correction.
+
+**Evidence.** Spike Axis 2: +30/-5 lines in `envrender.py`, 0/475 unit tests
+broken (pure, behavior-preserving refactor for the single-node case), and
+manually verified live that `render_kube_env` correctly colon-joins across
+**two** nodes' kube targets with `len(connections) == 2`, while `render_env`
+on the same input still raises `MultiNodeEnvUnsupported` (spike findings,
+"Part 2", Axis 2 row and the inline verification transcript).
+
+**Consequences.** `MultiNodeEnvUnsupported`'s scope narrows from "the whole
+kube-carrying export" to "the scalar channel only" — see decision 8
+(breaking-change policy) for why this is accepted rather than treated as a
+regression. `cli.py` needs a new call site to actually invoke
+`render_kube_env` for the multi-node case — see decision 6.
+
+**[Superseded in part by entry 13, iteration 3.]** `render_kube_env` itself
+— the function this entry extracts — is **not** superseded and ships
+unchanged (kube part, U4). What *is* superseded: `render_env`, the function
+it was extracted *from*, is later deleted in its entirety once the
+unified-output pivot removes the scalar channel it existed to produce (entry
+10). `MultiNodeEnvUnsupported` accordingly narrows all the way to zero raise
+sites and is removed too (entry 13), rather than staying narrowed-to-scalars
+as this entry originally concluded.
+
+## 3. Env export: conditional cardinality, not the superset
+
+**Context.** The ticket's work item 3 asked to prototype exporting
+`KUBE_CONFIG_PATH` and `KUBE_CONFIG_PATHS` alongside `KUBECONFIG` as a
+superset, explicitly deferring the final choice to a parallel
+provider-verification effort ("Whether they also honour plain `KUBECONFIG` is
+being verified by another agent in parallel").
+
+**Alternatives considered.**
+
+- **Superset, always** (spike Axis 3, as literally prototyped): export all
+ three unconditionally. Evidence: +10/-2 lines, exactly one test break —
+ `test_predicted_env_keys_matches_render_env` — and only when
+ `predicted_env_keys` isn't updated in the same commit (confirmed by
+ deliberately reverting only that half and re-running; 1 failure, clear diff,
+ 0 once both changed together). **Rejected once the provider findings landed
+ post-spike**: `docs/specs/2026-08-10-issue15-provider-env-precedence.md`
+ shows `KUBE_CONFIG_PATH` wins over `KUBE_CONFIG_PATHS` when both are set,
+ live-confirmed for both `hashicorp/kubernetes` v2.38.0 and
+ `hashicorp/helm` v2.17.0. Exporting both unconditionally the instant a
+ *second* kube target is materialized would silently shadow every cluster but
+ the one named by `KUBE_CONFIG_PATH` — the collision-class failure this whole
+ design exists to prevent, recreated one layer down in the env contract
+ itself.
+- **`KUBE_CONFIG_PATH` + `KUBECONFIG` only, drop `KUBE_CONFIG_PATHS`
+ entirely**: considered and rejected, because it has no way to express more
+ than one materialized file to the providers at all — a real regression for
+ any consumer with two or more kube targets on one or more nodes, which the
+ multi-node kube channel (decision 2) exists specifically to support.
+
+**Decision.** Conditional on the number of materialized kubeconfig files
+(one per kube target, summed across the whole envelope):
+
+- 0 files → nothing exported.
+- exactly 1 file → `KUBECONFIG` + `KUBE_CONFIG_PATH`, both pointing at the one
+ file; `KUBE_CONFIG_PATHS` **not** exported.
+- ≥ 2 files → `KUBECONFIG` + `KUBE_CONFIG_PATHS`, both the same colon-joined
+ list; `KUBE_CONFIG_PATH` **must not** be exported (it would win over the
+ list per the measured provider precedence, hiding every cluster but the
+ first).
+
+**Consequences.** `predicted_env_keys` must model the *same* cardinality
+condition — not just "kube_targets is non-empty" as it does today — and the
+anti-drift guard test is extended with cases for both cardinalities. This is
+strictly more surface than the superset would have needed (two branches
+instead of one flat set), but it is the only shape that does not have a
+silent-shadowing failure mode once the provider precedence fact is known.
+
+**[Superseded in part by entry 16, iteration 6 (R11).]** The claim that
+`predicted_env_keys` should model "the *same*" (i.e. exact) cardinality
+condition is wrong for `predicted_env_keys` specifically — it must instead
+*over-approximate* conservatively, because it runs pre-spawn against input
+cardinality, which can shrink by the time output cardinality is known (an
+optional node/target can fail). `render_kube_env`'s own export logic (the
+actual, output-side computation this entry establishes) is unaffected and
+stays exact — only the *predictor* becomes conservative. See entry 16.
+
+## 4. Naming scheme: `tunstrap--`, no configurable prefix
+
+**Context.** The identity strings need a scheme that is unique across nodes
+and targets without operator configuration.
+
+**Alternatives considered.** A configurable prefix (e.g. `--context-prefix`)
+was the natural next reach — it would let two independent tunstrap
+invocations avoid colliding even if `node`/`target` names happened to repeat
+across them. Not prototyped: rejected on the org rule cited directly in the
+ticket ("avoid excessive configurability") before implementation, on the
+grounds that the node name already solves the concrete scenario a prefix
+would be reached for (merging two separate tunstrap runs whose `node` dict
+keys already have to differ or the merge was already ill-defined for other
+reasons).
+
+**Decision.** Fixed `tunstrap--` for cluster, user and context
+alike (one shared name across all three, not three independently-derived
+strings) — no prefix option, no per-field naming variation.
+
+**Consequences.** Uniqueness is a corollary of `NodeInput`/`kube_targets`
+dict-key validation (`_validate_identifier_key`, `schemas.py:14-22`) rather
+than a property this feature has to separately maintain — no new uniqueness
+check is needed anywhere in the rename path. A future request for a
+configurable prefix is a new decision, not an oversight in this one.
+
+**[Superseded in part by entry 15, iteration 6 (R10).]** "No new uniqueness
+check is needed anywhere in the rename path" is **false**: dict-key
+validation only proves `node` and `target` are each individually valid
+identifiers, not that the hyphen-joined `tunstrap--` string is
+unique across different `(node, target)` pairs — `_FETCH_FILES_KEY_RE`
+permits internal hyphens, so `(node="a-b", target="c")` and `(node="a",
+target="b-c")` both join to `tunstrap-a-b-c`. A real validation-time
+collision check is added; see entry 15 for the alternatives considered and
+why detection (not structural prevention) was chosen.
+
+## 5. Rename scope: the active current-context triple only
+
+**[Corrected iteration 6 (R14) — read in conjunction with the note at the end
+of this entry, not in isolation.]**
+
+**Context.** A materialized kubeconfig may contain more than the
+current-context's cluster/user/context — `ignored_contexts` (the *collection*
+of skipped-context names) is computed in `parse_kubeconfig` at
+`kube.py:179-183`; the *warning* for each is actually logged where that list
+is consumed, in `run_kube_targets` at `kube.py:330-337`, not at the
+collection site itself — an earlier revision of this entry (and the design
+doc) cited the wrong function for the warning.
+
+**Alternatives considered.** Renaming (or pruning) every context/cluster/user
+in the document, not just the current one, was considered and rejected for
+this change. Reasons: (a) it changes the module's pre-existing, documented,
+tested contract that non-current entries are left byte-stable
+(`kube.py:1-8`), which is out of this ticket's stated scope; (b) the practical
+collision surface is the current-context triple, since k3s and kind — the two
+shapes this codebase actually targets — both ship single-context
+kubeconfigs; (c) it is real additional work (deciding prune vs. rename for
+entries no consumer will ever reach through `current-context`) that the
+ticket's own "one cluster per kube_target" framing does not ask for.
+
+**Decision.** Rename only the current-context's cluster, user and context
+entries, **but update every reference to them, including references from
+ignored (non-current) contexts** — corrected, see below. Every entry neither
+part of nor referencing the active triple stays untouched.
+
+**Consequences.** Accepted, documented residual risk: two materialized files
+whose *non-current* contexts happen to collide are not protected by this
+change. If that ever becomes a real incident rather than a theoretical one,
+the fix is pruning ignored entries at materialization time — a separate,
+larger change, explicitly out of scope here (see the design doc's "Out of
+scope" section).
+
+**[Corrected, iteration 6 — R14.]** An earlier revision of this decision's
+"Decision" line read "Leave every other entry in the document untouched,"
+full stop. That is incomplete in a way that produces a real defect: a
+kubeconfig can legitimately have a *non-current* (ignored) context whose own
+`context.cluster`/`context.user` reference the **same** cluster/user entry
+the current context also uses (two contexts sharing one cluster with
+different users is an ordinary shape). If the shared cluster/user entry is
+renamed but the ignored context's reference to it is left pointing at the
+*old* name, that reference now dangles — it names an entry that no longer
+exists anywhere in the document under that name, which is strictly worse
+than the pre-rename state. The rename must therefore walk **every** reference
+to the renamed cluster/user, not just the current context's own — "leave
+every other entry untouched" is now read as "leave every entry that neither
+*is* nor *references* the active triple untouched," a narrower and correct
+claim. See the design doc's "Rename scope" section (iteration 6) and the
+plan's Task 1 for the concrete fix and its regression test.
+
+## 6. `cli.py` wiring is in scope for this ticket
+
+**Context.** The spike's own open question asked whether wiring `run`'s
+`_build_child_env` to actually call the new multi-node-safe `render_kube_env`
+was part of this ticket or a follow-up, since the spike itself only proved the
+export *function* works multi-node — it never wired a call site, to keep the
+spike's diff isolated to `envrender.py`/`kube.py`.
+
+**Decision (ruling, not re-litigated here).** In scope. Without it, a
+multi-node `run` invocation with kube targets never actually emits
+`KUBECONFIG`/`KUBE_CONFIG_PATH(S)` for the child process — the multi-node kube
+channel would be dead code, reachable only by unit tests calling
+`render_kube_env` directly, never by an actual `tunstrap run` invocation.
+
+**Consequences.** `_build_child_env` (`cli.py:365-407`) needs a second,
+independent call — `render_kube_env(output)` merged into `child_env` whenever
+any node carries `kube_targets`, regardless of the existing `inject_scalars`
+gate. This is new code beyond what the spike prototyped; see the
+implementation plan for the concrete change and its test.
+
+## 7. `suppress_kubeconfig` extends to all three exported kube env vars
+
+> **[Issue #14 fix, iteration 9 — correction annotation, this entry's decision
+> is superseded, not rewritten in place.]** The "Decision" below shipped and
+> was falsified: it suppressed `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` through
+> `tunstrap_tofu`, which is Mode A's only delivery channel through that exact,
+> documented entry point — not a fallback path for it. Measured by the
+> reporter against a real tunnel: through `tunstrap_tofu` all three names came
+> back unset, and a provider block following Mode A's own item 1 (only
+> `config_context` set) failed against the inert `localhost:80` loopback. This
+> entry's "Context"/"Alternatives considered"/"Decision"/"Consequences" below
+> are kept verbatim as the historical record of what iteration 6 actually
+> shipped (the same annotate-don't-rewrite discipline entry 14 applied to
+> entry 11); see entry 20 for the current, narrowed, superseding decision.
+
+**Context.** Not raised by the ticket or by any ruling — found while
+specifying the env-export contract (decision 3). `tunstrap_tofu` sets
+`suppress_kubeconfig=True` so a broken `TF_VAR_tunstrap` → `config_path` chain
+fails loudly rather than silently reaching the cluster through a
+still-present `KUBECONFIG` (`tofu_proxy.py:138-155`, `cli.py:388-392`). **[Editorial
+fix, iteration 6 — narrowed]** The provider findings (decision 3's evidence)
+show that guard has been inert **only for the two providers' own Go
+configuration chain** — neither provider's `initializeConfiguration()`/
+`newKubeConfig()` ever read plain `KUBECONFIG`. An earlier revision of this
+entry said "inert all along" without that qualifier, which overstated the
+claim: the same suppression was, and remains, load-bearing for a *different*
+audience the whole time — `tofu`'s children include `local-exec`
+provisioners and `external` data sources that can shell out to the
+`kubectl`/`helm` **CLIs** directly, both of which do honour plain
+`KUBECONFIG`. It becomes genuinely load-bearing **for the provider-native
+chain specifically, for the first time**, the moment this design starts
+exporting `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`, which the providers do
+read.
+
+**Alternatives considered.** Leaving `suppress_kubeconfig` as-is (dropping
+only `KUBECONFIG`) was the default until this was noticed; not viable once
+stated plainly, since it would silently defeat the one property the proxy's
+own docstring claims to guarantee.
+
+**Decision.** `suppress_kubeconfig` drops all three names —
+`KUBECONFIG`, `KUBE_CONFIG_PATH`, `KUBE_CONFIG_PATHS` — both inherited and
+injected, whenever set.
+
+**Consequences.** One small, mechanical change to `_build_child_env`
+(`cli.py:394-404`); no change to `tunstrap_tofu`'s own call site
+(`suppress_kubeconfig=True` already requests "suppress the kube env", its
+meaning just becomes complete). See the plan for the concrete diff and its
+test (`tests/unit/test_cli_run_output_var.py` or a new focused test asserting
+all three names are absent from the child env under
+`suppress_kubeconfig=True` with a multi-file payload).
+
+## 8. Breaking-change policy: deliberate, no compatibility shim
+
+**Context.** The rename changes the value of `KubeTargetOutput.context_name`/
+`.cluster_name` for every consumer of the materialized kubeconfig or the
+`--output-var` payload; narrowing `MultiNodeEnvUnsupported`'s scope changes a
+documented, tested exception contract.
+
+**Alternatives considered.** A compatibility flag (e.g.
+`daemon.rename_kube_identities: bool`, defaulting to the old behaviour) was
+considered and rejected without prototyping. The org rule the ticket itself
+cites is explicit: no backward compatibility unless instructed. A flag would
+also mean two code paths to test and maintain for a rename whose only
+consumers are documented, internal (the recipe this design also ships) and
+not yet depended on by any released version.
+
+**Decision.** Breaking, deliberately, with no flag and no fallback:
+
+- Upstream context/cluster/user names in the materialized kubeconfig always
+ change to `tunstrap--`.
+- `KubeTargetOutput.context_name`/`.cluster_name` always report the new
+ names.
+- `MultiNodeEnvUnsupported`'s contract narrows to the scalar channel only,
+ matching what its own docstring already claimed.
+
+**Consequences.** Any external consumer relying on the upstream cluster's own
+context name surviving verbatim through tunstrap breaks with this change,
+with no opt-out. This is accepted per the org rule and stated explicitly here
+so it is not mistaken for an oversight during review.
+
+**[Partially superseded by entry 13, iteration 3.]** The rename bullets (first
+two) stand unchanged. The third bullet — "`MultiNodeEnvUnsupported`'s contract
+narrows to the scalar channel only" — is superseded: entry 13 removes the
+class entirely rather than leaving it narrowed. The **policy** this entry
+establishes (breaking, deliberately, no compatibility shim) is what entry 13
+applies to justify the further deletion; the specific narrowing outcome is
+what changed, not the policy behind it.
+
+## 9. Kube channel fires independently of node count — a pre-existing test's contract is deliberately inverted
+
+**[Superseded by entry 13, iteration 3 — kept below verbatim as the historical
+record, not rewritten.]** This entry's *conclusion* (kube channel fires on
+`kube_targets` presence, not node count) still holds and is in fact easier to
+satisfy under the pivot. What is superseded is the *mechanism* it specified
+to get there — the two-branch `_build_child_env` (`inject_scalars=True` →
+`render_env` delegating to `render_kube_env`; `inject_scalars=False` →
+`render_kube_env` directly) — because `render_env` and `inject_scalars` are
+both removed by the pivot (entries 10/13), leaving one unconditional
+`render_kube_env(output)` call with no branch at all. The test this entry
+retargets is retargeted *again* under the pivot, for a different reason (the
+scalar-leak assertion it still carried no longer makes sense once scalars do
+not exist as a concept to leak) — see entry 13 and the plan's **Task 5 Step 2**
+(corrected pointer, iteration 4: the cli.py wiring and scalar-removal work
+this entry's mechanism affects both live in Task 5, not Task 4, under the
+plan's iteration-3 task renumbering — Task 4 is the unified-output shape
+task, a pure-function step with no `_build_child_env` changes at all).
+
+**Context.** Decision 6 established that `cli.py` wiring for the multi-node
+kube channel is in scope. Working out that wiring's exact trigger condition
+(§6's own text, and the design doc's "`cli.py` wiring is in scope" section)
+surfaced a sharper rule than "fires when `inject_scalars` is false": **the
+kube channel fires whenever `kube_targets` are present in the output, full
+stop — independent of node count and independent of why `inject_scalars`
+happens to be false.** `inject_scalars` decides which function computes the
+keys (`render_env`, which delegates, vs. `render_kube_env` directly); it never
+decides whether they get computed.
+
+This directly contradicts a **pre-existing, deliberately-written** test:
+`tests/unit/test_cli_run_output_var.py::test_multi_node_suppression_uses_input_count`
+(added under the pre-#15 `run` env I/O design) asserts, among other things,
+`"KUBECONFIG" not in FakePopen.last_env` for a two-input-node run whose one
+surviving output connection carries a materialized kube target. That
+assertion encoded the *old* contract — "multi-node input ⇒ no kube env at
+all" — which decisions 2 and 6 above deliberately supersede.
+
+**Alternatives considered.** Leaving the old assertion in place and adding a
+node-count exception to the new rule (e.g. "kube channel fires on
+`kube_targets` presence, except when the *input* had more than one node and
+only one survived") was considered and rejected: it reintroduces exactly the
+kind of node-count-conditional kube-channel logic this whole design exists to
+remove, for the sole purpose of keeping one old assertion green, and it would
+leave the multi-node kube channel just as reachable as before, since a
+survivor-of-multiple-optional-nodes shape is not distinguishable from a
+"real" multi-node output at the type level.
+
+**Decision.** The old assertion is wrong under the new contract and is
+retargeted, not preserved: `test_multi_node_suppression_uses_input_count` is
+renamed to `test_multi_node_suppresses_scalars_but_exports_kube_channel` and
+its `KUBECONFIG`-absence assertion is flipped to a `KUBECONFIG`/
+`KUBE_CONFIG_PATH`-presence assertion (exactly one file survives in this
+test's payload). The test's other half — that the `TUNSTRAP_*` scalars stay
+suppressed, decided by the *input* node count and not `len(out.connections)`
+— is unchanged and remains the one place that half of the contract is
+falsifiable; only the kube-channel half of the old assertion was ever wrong
+under the new design. See the plan's iteration-2 Task 4 Step 2 for this
+retarget as it landed at the time (historical citation, not re-resolved here
+— the pivot's iteration-3 task renumbering moved the equivalent *area* of
+work, and the *second* retarget this test undergoes, to the current plan's
+Task 5; see entry 13 and its own corrected pointer above).
+
+**Consequences.** Anyone reading `git blame` on that test past this point
+sees a deliberate contract inversion, not a silent weakening — which is why
+it is recorded here by name rather than only in the plan's own commit
+message. No other test in the suite encoded the old "no kube env for
+multi-node, at all" contract (confirmed by grep across `tests/unit/` for
+`KUBECONFIG` assertions during this revision), so this is the only retarget
+this decision requires.
+
+---
+
+## Iteration 3: the unified-output-contract pivot
+
+Entries 10-13 record a user-directed design pivot, not a discovery made while
+implementing entries 1-9. Where a ruling is given rather than derived, that is
+stated plainly rather than reverse-engineered into a false "alternatives
+considered."
+
+## 10. Unified node-qualified output contract replaces the flat scalar channel
+
+**Context.** The pre-pivot design (entries 1-9) kept the `TUNSTRAP__*`
+scalar channel for single-node output and added a parallel, node-agnostic
+kube channel (`render_kube_env`) alongside it. The user's pivot rejects that
+two-channel shape entirely: the *entire* consumer-facing output — ports, kube
+references, session metadata — becomes one unified, node-qualified JSON
+structure, replacing the scalar channel outright rather than extending it.
+
+**Alternatives considered.**
+
+- **Extend the scalars with a node dimension** (e.g.
+ `TUNSTRAP___PORT`, or a separate `TUNSTRAP_NODES` listing
+ key). Rejected by the user's own reasoning, encoded here rather than
+ re-derived: this is two mechanisms doing the same job — a flat `KEY=VALUE`
+ scalar space and a structured JSON value both trying to represent a
+ hierarchy — when the domain has one natural, unambiguous representation
+ (nested keys) that scalars cannot express without inventing a second
+ encoding scheme layered on top of environment-variable naming rules
+ (`_key()`'s `[^A-Z0-9]` → `_` sanitisation already loses information for
+ non-trivial names; stacking a node segment on top compounds that). Scalars
+ are also the wrong abstraction for the general case: `fetch_files` content
+ and multi-field kube references (`path`/`context`/`endpoint`) do not fit a
+ single scalar value at all — the pre-pivot design already routed those
+ through the structured `--output-var` channel instead, which is the
+ existing proof that the domain wants a structure, not more scalars.
+- **Status quo: keep both the scalar channel (single-node) and the separate
+ kube channel (node-agnostic), unified only within `--output-var`'s own
+ existing projection.** This is entries 1-9's actual shipped design.
+ Rejected by the pivot: it leaves three channels (scalars, kube env,
+ `--output-var`) each with a different node-count contract, which is exactly
+ the kind of multi-mechanism-for-one-need shape the first alternative was
+ also rejected for, just already partially built rather than newly proposed.
+
+**Decision.** One unified structure (design doc, "The unified output
+contract", shape sketch) replaces the scalar channel outright.
+`TUNSTRAP__*` and the per-kube-target `TUNSTRAP__*` scalars are
+deleted, not deprecated-with-a-flag (decision 8's breaking-change policy
+extends to this too — see entry 13). `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`
+survive as two of three non-target-scoped scalars, kept for a real
+bootstrapping need (locating the payload from a shell context that has not
+parsed anything), not for backward compatibility. **[iteration 4 addition]**
+A **third** survivor, `TUNSTRAP_OUTPUT_FILE` (the materialized JSON path),
+was added while tracing `render_env`'s deletion through `start --output env`
+— dropping to only two survivors leaves that mode with no way to tell a
+plain-`remote_targets` shell consumer where a forwarded port landed, a real
+functional regression the design doc's "The scalar channel is removed"
+section now records as a judgment call, not a silent narrowing.
+
+**Consequences.** `render_env` (the scalar-producing function) is deleted.
+`render_output_var`'s internals change to build the new shape (signature
+unchanged: still `OutputSchema -> str`). A new `render_unified_output`
+function/model pair is needed (design doc shape). Every test asserting a
+`TUNSTRAP__*` key breaks and must be deleted or retargeted, not
+patched around — see the plan.
+
+## 11. Materialization-primary + var-as-convenience + explicit stability contract
+
+**[Corrected in part by entry 14, iteration 6 (R9) — read together, not in
+isolation.]** This entry's core conclusion — materialization is primary,
+both channels ship, the reasoning cited to findings #1/#2/#6 — stands. What
+is corrected: the specific **mechanism** this entry originally described for
+the var form ("the var is a locator and convenience path... the var only to
+locate the file") is retracted by entry 14 as unsound (a three-model
+red-team review found the locator pattern does not actually buy plan-safety
+— see entry 14's alternatives-considered for the full reasoning). Read this
+entry's "Decision"/"Consequences" below as describing the *what* (two
+channels, materialization primary); read entry 14 for the corrected *how*
+(three independent modes, no locator).
+
+**Context.** U2/U6: the unified structure is delivered both as the
+`--output-var` value and as a materialized JSON file, and the user decided
+materialization is primary. This directly touches ticket #15's own framing
+("connection data should stop travelling through Terraform input variables")
+and the pre-pivot recipe's "no connection data in input variables" condition,
+because the var form of the unified structure **does** carry live connection
+data (host:port strings) — an honest tension, not a technicality.
+
+**Alternatives considered.**
+
+- **Var-only delivery** (the pre-pivot `--output-var` shape, just reshaped):
+ rejected by the user's explicit ruling (U2) that materialization is
+ primary, on the strength of findings #1 and #6 (below) — a var-only design
+ has no plan-safe path for a consumer who reuses a saved plan across a
+ tunstrap restart.
+- **Materialization-only delivery** (drop `--output-var` entirely): would
+ satisfy the ticket's stricter "nothing live enters Terraform" framing most
+ completely, and was the closest fit to ticket #15's own words. Rejected:
+ U2 explicitly keeps the var form ("delivered BOTH as... AND materialized"),
+ and dropping it removes the only channel through which HCL can discover
+ *anything* without already knowing a session path — Terraform config can
+ only see env vars that are `TF_VAR_*`-mapped or read by a provider's own
+ Go code (like `KUBE_CONFIG_PATH`); a plain shell env var pointing at a file
+ path is invisible to HCL entirely unless also injected as a `TF_VAR_*`.
+ Materialization-only would need a *different* bootstrap mechanism the user
+ did not ask for.
+
+**Decision.** Both channels ship, materialization primary:
+
+- `--output-var NAME` still injects the full unified JSON as a string (var
+ form, convenience/bootstrap).
+- `run` unconditionally materializes the same JSON to
+ `/tunnel-data/output.json` (new; not gated by `--output-var`).
+- **Reasoning, cited to the ticket's own findings, restated in full here per
+ the instruction that this ADR entry carry it:**
+ - **Finding #1** (provider configuration IS re-evaluated at apply — a
+ `file()` read inside a provider block picks up a post-plan change) is
+ *why* the materialized file is plan-safe: reading it via `file()` inside
+ the provider config block re-reads current content at apply time.
+ - **Finding #6** (binding a connection value to a `var.` trips "Mismatch
+ between input and plan variable value" on a saved plan — the ticket's own
+ negative control) is *why* the var form is not plan-safe: its decoded
+ values are frozen into the plan at `plan` time, and a tunstrap restart
+ before `apply` risks the mismatch.
+ - **Finding #2** (outputs freeze silently — `file()` through an output
+ returns the plan-time value at apply, no error) belongs in the stability
+ contract text, not just the reasoning here, because it is the trap that
+ defeats finding #1's plan-safety guarantee if the materialized file is
+ read through an intermediate `output` rather than directly at the point
+ of use — see the design doc's "Stability contract" subsection, which
+ states this as an explicit consumer-facing rule, not just an internal
+ rationale.
+
+**U6 reconciliation, recorded here as the decision's own rationale (also
+stated in the design doc for the consumer-facing read).** **[Corrected,
+iteration 6 — R12: an earlier revision of this paragraph scoped the
+reconciliation too narrowly, by kind of *data* rather than kind of
+*channel*.]** Ticket #15's "nothing live enters Terraform" holds in full,
+unconditionally, only for the **kube env channel**
+(`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`) — no variable, no file, ever. It is
+**superseded** for both ports *and* kube **references** (`path`/`context`/
+`endpoint`, non-credential per U4) once a consumer binds `--output-var`: an
+earlier revision claimed the kube framing was "untouched," which is only
+true for the env channel — the moment a consumer reads
+`nodes..kube..path` (or `context`/`endpoint`) out of
+`var.tunstrap`, that is connection data travelling through a Terraform input
+variable, exactly what the ticket wanted stopped, whether or not the field
+is itself a credential. For ports specifically, no env-native path exists at
+all — a generic TCP endpoint is not a Terraform-provider convention the way
+`KUBE_CONFIG_PATH` is — so a `host:port` value has nowhere to go except into
+HCL as a value, one way or another. Given that constraint,
+materialization-primary + var-as-convenience + the explicit stability
+contract (entry 14's corrected mechanism) is the closest available
+approximation to the ticket's stricter framing that a live TCP endpoint can
+actually achieve, and it is adopted as superseding the ticket's stricter
+framing **for the unified structure's var form — ports and kube references
+both — while the kube env channel's full compliance is untouched.**
+
+**Consequences.** A new materialization writer is needed (plan). The recipe
+gains a stability-contract section a consumer must read before choosing
+which form to bind to a resource. This is real new surface area (a second
+delivery path with its own failure mode) that a var-only or
+materialization-only design would not have had — accepted as the cost of
+satisfying both U2's explicit requirement and a genuine plan-safety need
+neither alternative covers alone.
+
+## 12. "через js" is read as JSON/jsondecode, not literal JavaScript — recorded assumption
+
+**Context.** U5: the user's instruction for consumer-side transformation used
+the phrase "через js." This stack (Terragrunt, OpenTofu, HCL) has no
+JavaScript runtime anywhere in the consumer chain.
+
+**Alternatives considered.** Silently interpreting the phrase as "JSON" and
+moving on (no explicit record) was the default temptation; rejected per the
+standing instruction to record interpretations rather than silently apply
+them, and because a genuinely ambiguous phrase deserves a durable record of
+which reading was taken, so a reviewer who meant something else (e.g. a
+literal `local-exec` calling `node`) can catch the mismatch cheaply instead
+of discovering it after implementation.
+
+**Decision.** "через js" is read as **JSON**, consumed via HCL's `jsondecode`
+function inside `locals`, exactly the mechanism the pre-pivot `--output-var`
+recipe already used. No JavaScript runtime, no `local-exec` invoking `node`,
+no new runtime dependency anywhere in the design.
+
+**Consequences.** If this reading is wrong, it is wrong in a single,
+clearly-labelled place (design doc "Consumer-side transformation", this
+entry) rather than baked silently into fifteen sentences of recipe prose —
+cheap to correct if a future reviewer disagrees.
+
+## 13. `MultiNodeEnvUnsupported` and `inject_scalars` are removed, not narrowed further
+
+**Context.** Entries 2 and 9 narrowed `MultiNodeEnvUnsupported` to "the
+scalar channel only" and built a two-branch `_build_child_env` keyed on
+`inject_scalars`. The pivot's own stated semantics (design doc, requirement
+text: "the `inject_scalars` gate semantics change: unified output is emitted
+regardless of node count") removes the scalar channel that both of these
+existed to gate.
+
+**Alternatives considered.** Keeping `MultiNodeEnvUnsupported` as an unused,
+unraisable class "for future use" was considered and rejected: every
+remaining channel (unified output, kube env) is either structurally
+node-safe (nested keys) or already node-count-agnostic by design (the kube
+channel), so there is no remaining scenario that class could ever describe.
+An exception class with zero reachable raise sites is exactly the kind of
+dead code `vulture`'s gate exists to catch, and per the org's no-backward-
+compatibility rule there is no reason to keep it as a courtesy.
+
+**Decision.**
+
+- `MultiNodeEnvUnsupported` is deleted: the class, its `_EXIT_CODES` entry,
+ and both raise sites (`render_env`'s internal guard — moot anyway since
+ `render_env` itself is deleted; and `cli.py:640`'s pre-spawn
+ multi-node-without-`--output-var` gate, which is removed because
+ materialization now covers multi-node unconditionally, so the thing that
+ gate used to force an opt-in for no longer needs one).
+- `inject_scalars` (the boolean, its `len(schema.nodes) == 1` computation at
+ `cli.py:648`, and its threading through `_run_child`/`_supervise_child`/
+ `_build_child_env`) is deleted. Nothing left needs to know the node count
+ to decide what to compute — `render_kube_env` and `render_unified_output`
+ are both called unconditionally.
+- `_build_child_env` collapses to: always call `render_kube_env(output)`,
+ always build+inject the unified structure per `--output-var`'s presence,
+ always materialize it for `run`. No node-count branch anywhere in this
+ function.
+
+**Consequences.** This is a bigger ripple than entry 9's retarget: every test
+asserting `inject_scalars`'s value, mocking it, or asserting
+`MultiNodeEnvUnsupported`'s exit code (1) needs deletion or a rewrite to the
+new "multi-node succeeds unconditionally" behaviour — including entry 9's own
+retargeted test, `test_multi_node_suppresses_scalars_but_exports_kube_channel`,
+which is retargeted *again* (its "no `TUNSTRAP_*` scalars leak" assertion no
+longer describes a real guard once there is no scalar-producing code path
+left to leak from) — see the plan's **Task 5's grep-driven blast-radius
+enumeration** for the concrete, exhaustive list (iteration 4: this ripple was
+first accounted for case-by-case across three review rounds before being
+fixed systemically — see "Iteration 4" note below).
+
+**Not everything in this ripple is a deletion.** `test_predicted_env_keys_
+matches_render_env` (the anti-drift guard between `predicted_env_keys` and
+the actual injected-key computation) is **retargeted, not deleted** — the
+two-independent-implementations problem it guards against does not go away
+just because one of the two implementations (`render_env`) is replaced by
+another (`_build_child_env`'s own hardcoded-plus-`render_kube_env` logic);
+see the design doc's "Anti-drift guard extension" subsection, iteration-4
+addendum, for why the guard survives in re-scoped form. This was itself a
+drill-caught defect in an earlier revision of this plan, which had deleted
+the guard on the (false) premise that only one implementation remained.
+**[Further evolved by entry 16, iteration 6 — R11.]** The re-scoped,
+single-equality form this entry describes is itself superseded once
+`predicted_env_keys` becomes a conservative over-approximator rather than an
+exact predictor (entry 16): exact equality can no longer hold in general, so
+the guard splits into a formula test (exact equality against a
+hand-computed expected set) plus a safety-envelope test (`actual ⊆
+predicted`, driven by a cardinality-shrink case). See entry 16 for the full
+reasoning — this entry's framing of "retargeted, not deleted" is the
+conclusion that still holds; its specific single-equality mechanism does
+not.
+
+`cli.py:640`'s removal also means the exit-code table in the pre-#15 design
+spec (`docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`, "Error
+handling") has one fewer row for multi-node input; that spec is left as-is
+per its own "kept as-is, not superseded" status (this ADR's header) — the
+row simply no longer reflects current behaviour, which is normal for a design
+doc describing a point-in-time decision that a later ADR entry overrides,
+and is recorded here rather than edited into that older, closed doc.
+
+**Iteration 4 note — systemic fix, not a fourth case-by-case patch.** The
+first three review rounds each caught this same defect class in a different
+single spot (a test here, a stale reference there). Iteration 4's fix is a
+full grep-driven enumeration of every symbol/shape the pivot removes, across
+`tunstrap/`, `tests/unit`, `tests/integration`, `tests/e2e`, and `docs/`, with
+an explicit disposition per hit, made the authoritative blast-radius record
+inside the plan's Task 5 rather than trusted to be found again by inspection.
+See the plan for the table; it is not duplicated here.
+
+---
+
+## Iteration 6: three-model red-team review corrections
+
+Entries 14-18 record sarge's rulings on a consolidated 12-finding red-team
+review (three independent models). Where a ruling is given rather than
+derived, that is stated plainly, matching the discipline entries 10-13
+already established for user-directed decisions.
+
+## 14. The var-locator pattern is unsound and retracted; three independent delivery modes replace it
+
+> **[R16, iteration 7 — correction annotation, this entry's decision is
+> further superseded, not rewritten in place.]** Mode 2 below (the
+> literal, caller-pinned `--session-dir` file) is itself retracted by
+> iteration 7's user-confirmed direction: the session root stays ephemeral
+> unconditionally, and `TUNSTRAP_OUTPUT_FILE` — an env-carried locator, not
+> a pinned path — replaces it. Delivery collapses from three modes to two.
+> This entry's "Decision"/"Consequences" below are kept verbatim as the
+> historical record of what iteration 6 actually shipped (the same
+> annotate-don't-rewrite discipline this entry itself applied to entry 11);
+> see entry 19 for the current, superseding decision.
+
+**Context.** Entry 11 shipped a hybrid delivery mechanism: inject
+`var.tunstrap` and materialize a file, with the recommended plan-safe
+pattern being "read `var.tunstrap` only to locate the file
+(`jsondecode(var.tunstrap).session.session_dir`), then `file()` the located
+path." A three-model red-team review (findings #1, #5) found this unsound.
+
+**Alternatives considered.**
+
+- **The locator pattern itself** (entry 11's original mechanism): rejected
+ for three independent, compounding reasons: (1) finding #1 measured
+ content-change tolerance at a **stable** path, but the locator pattern put
+ a **changing** value (`var.tunstrap`'s full JSON, different every
+ invocation) into a Terraform variable — OpenTofu's plan-variable
+ consistency check (finding #6) compares the **whole bound value** of a
+ root-module variable between `plan` and `apply`, not just the sub-fields an
+ expression reads, so reading only `session.session_dir` from it does not
+ narrow the exposure at all; (2) this is the same finding #6 firing,
+ restated — the locator is exactly as exposed as binding the ports
+ directly; (3) the **default** session directory (auto-minted via
+ `tempfile.mkdtemp` when no `--session-dir` is given, `cli.py:427`) is
+ deleted by teardown, so even ignoring (1)/(2), a locator pointing at it
+ would frequently reference a directory that no longer exists by the time a
+ later, separate `apply` tried to read it.
+- **Materialization-only, drop the var entirely**: re-considered here (entry
+ 11 already rejected it, reasoning unchanged) — still rejected, since a
+ plain shell env var pointing at a file path is invisible to HCL unless also
+ `TF_VAR_*`-mapped, and dropping the var removes the only bootstrap
+ mechanism HCL has for anything it does not already know a literal path to.
+- **Keep the var, drop the locator recommendation, downgrade the var to
+ one-shot-only, and make the file mode's plan-safety depend on an
+ *operator-chosen* literal path rather than anything decoded from the
+ var**: **adopted** — this is the only option that is honest about what
+ finding #6 actually measures (the whole-variable comparison) rather than
+ trying to work around it with an indirection that does not change what is
+ compared.
+
+**Decision.** Three genuinely independent delivery modes, none locating
+another:
+
+1. Kube env channel — unchanged, plan-safe unconditionally, no var, no file.
+2. Unified file at a **literal, caller-pinned** path — plan-safe **only**
+ when the caller supplies a stable `--session-dir` (verified against
+ `session.py`: a caller-supplied root is `generated=False`; cleanup on
+ that path removes only `tunnel-data/`, never the root) and the consumer's
+ HCL hardcodes that same path as a literal, never derived from
+ `var.tunstrap`.
+3. `--output-var` (var form) — one-shot `plan && apply` in the same
+ invocation only. No saved-plan-reuse exemption of any kind, including via
+ a locator — corrected from entry 11's original recommendation.
+
+**Consequences.** Every example in the design doc and the recipe using
+`var.tunstrap_session_dir` (or any locator pattern) is deleted, not adapted —
+there is no variant of the locator that survives this correction. The
+recipe's Mode B (design doc, "Documentation") is rewritten to show the
+literal-path pattern exclusively for the plan-safe case, with the var form
+demoted to explicitly one-shot. See the design doc's "Delivery" and
+"Stability contract" subsections (rewritten, iteration 6) for the shipped
+contract, and entry 11 above (annotated, not rewritten) for the decision
+this corrects.
+
+## 15. Naming collision detection: validation-time check, not structural prevention
+
+**Context.** Entry 4 claimed the `tunstrap--` join was unique
+by construction. False: `_FETCH_FILES_KEY_RE` (`schemas.py:11`) permits
+internal hyphens in both `node` and `target`, and the join itself uses a
+hyphen, so `(node="a-b", target="c")` and `(node="a", target="b-c")` both
+render `tunstrap-a-b-c`. This is a different defect class from the mandatory
+k3s-style collision test (entry 4 area / design doc "Testing contract"):
+that test proves *upstream* kubeconfig names colliding is fixed by the
+rename; this defect is tunstrap's *own* scheme colliding with itself,
+independent of any upstream content.
+
+**Alternatives considered.**
+
+- **Change the join separator** (e.g. a character neither `node` nor
+ `target` can contain) to prevent the collision structurally rather than
+ detect it: rejected as a larger, unrequested change — it alters the
+ user-visible naming scheme itself (`tunstrap--`'s exact
+ rendered form), which nothing in the ticket or the pivot asked to change,
+ for a defect a validation check closes just as completely.
+- **Tighten `_FETCH_FILES_KEY_RE` to forbid hyphens in `node`/`target`
+ entirely**: rejected — this is a shared regex used for `fetch_files`,
+ `kube_targets`, and node keys generally (`schemas.py:11`, `_validate_
+ identifier_key`); narrowing it to solve a kube-identity-naming problem
+ would remove a legitimate character from every other identifier in the
+ schema for an unrelated reason, and does not fully solve the problem
+ either (two *node* names differing only in an internal hyphen could still
+ collide against two different *target* names symmetrically).
+- **Validation-time collision check across every `(node, target)` pair**:
+ **adopted** — computed once, at `InputSchema` validation, before any SSH
+ connection is attempted; rejects the payload with an error naming the
+ exact colliding pairs.
+
+**Decision.** Add a collision check at schema-validation time: for every
+`(node, target)` pair across all nodes' `kube_targets`, compute the rendered
+`tunstrap--` name; if any two pairs render the same string,
+reject the whole payload with an error identifying both colliding pairs by
+name. The hyphen join itself is kept unchanged.
+
+**Consequences.** A new unit test drives exactly the `(a-b, c)` vs. `(a,
+b-c)` pair (design doc, "Testing contract," R10/R14 section) — not covered
+by the existing mandatory k3s-style test, which must not be assumed to also
+exercise this defect class. See the plan for the concrete validator and its
+test.
+
+## 16. `predicted_env_keys` becomes a conservative superset predictor; the anti-drift guard becomes two-part
+
+**Context.** Entry 3 had `predicted_env_keys` model the *exact* same
+cardinality-conditional rule `render_kube_env`'s actual export uses. A
+red-team finding, logic-verified, shows this under-reserves: `predicted_env_
+keys` runs pre-spawn against the *input* schema's declared cardinality, but
+the *actual* materialized cardinality can be **smaller** — an optional
+(`required: false`) node or kube target can fail without failing the run
+(`manager.py:99-107` already builds successful-only `connections`). Two
+kube targets declared, one optional node fails → one file actually
+materializes → the real export uses the `==1` branch (`KUBE_CONFIG_PATH`),
+but the exact predictor would have predicted the `≥2` branch
+(`KUBE_CONFIG_PATHS` only) and **not reserved `KUBE_CONFIG_PATH`** — a
+`--output-var KUBE_CONFIG_PATH` would then pass the pre-spawn collision
+check and be **silently overwritten** by the real export.
+
+**Alternatives considered.**
+
+- **Exact cardinality prediction** (entry 3's original design): rejected, per
+ the failure mode above — it under-reserves whenever cardinality shrinks
+ between input and output, which is a normal, expected outcome of the
+ `required: false` feature this codebase already has, not an edge case.
+- **Compute the predictor from a live probe of what will actually
+ materialize** (e.g. attempt each kube target's fetch before validating):
+ rejected — `predicted_env_keys` is explicitly a **pre-spawn**, no-SSH-yet
+ check (its whole purpose is rejecting a bad `--output-var` NAME before a
+ daemon exists); making it probe live connectivity would defeat that
+ purpose and reintroduce the exact daemon-orphan risk window the pre-#15
+ design's "Cleanup must own the whole post-spawn window" invariant was
+ written to close.
+- **Reserve conservatively: whenever any node declares `kube_targets` at
+ all, reserve all three kube names, regardless of exact count**: **adopted**
+ — deliberately over-reserves; the asymmetry is the point. Over-reserving
+ can only reject *more* `--output-var` names than strictly necessary (a
+ cheap, immediately visible usage error); under-reserving risks a silent
+ post-spawn collision, which is the exact failure this check exists to
+ prevent.
+
+**Decision.** `predicted_env_keys` reserves `{KUBECONFIG, KUBE_CONFIG_PATH,
+KUBE_CONFIG_PATHS}` whenever any node's input schema declares
+`kube_targets`, unconditional on exact count. `render_kube_env`'s actual
+export (entry 3) is unaffected and stays exact, cardinality-conditional —
+only the predictor changes.
+
+**The anti-drift guard becomes two independent tests, not one equality**
+(superseding entry 13's single-equality retarget, see the note there):
+
+1. A **formula test** (exact equality, unit-test style): `predicted_env_
+ keys(schema)` equals a hand-computed expected set for a representative
+ schema, proving the conservative *formula* is implemented correctly.
+2. A **safety-envelope test** (subset, the actual anti-drift property):
+ `set(actual injected keys) ⊆ predicted_env_keys(schema)`, driven by a
+ **cardinality-shrink** scenario (two kube targets declared, one optional
+ node fails, one file materializes) — the case that would falsify a
+ predictor that got the direction of the conservatism backwards.
+
+**Consequences.** `predicted_env_keys`' own unit tests gain a case proving
+the conservative reservation fires on *any* `kube_targets` presence, not
+just above some count threshold. The design doc's "Anti-drift guard
+extension" and "Env-export contract" sections carry the same correction; the
+plan's Task 3 (formula) and Task 5 (safety-envelope) guard literals are
+rewritten accordingly.
+
+## 17. Materialization writer: true atomic replace, not mode-fixed-at-creation alone
+
+**Context.** Entry 11 (and the design doc, prior to iteration 6) described
+the materialization write as reusing "the atomic-secure-write primitive,"
+`SessionDir._write_file` (`session.py:132`, `os.open(path,
+O_CREAT|O_WRONLY|O_TRUNC, 0o600)`). A red-team finding: `O_TRUNC` + write in
+place is **not** atomic — a reader (a consumer's `file()` call racing a
+`run` restart that rewrites the same pinned path, entry 14's mode 2) can
+observe a truncated-but-not-yet-rewritten file mid-write. `_write_file`'s
+real, load-bearing property is **mode-fixed-at-creation** (no separate
+`chmod`, no window of broader-than-`0600` permissions) — a different
+property from atomicity, conflated by the word "atomic" in earlier text.
+
+**Alternatives considered.**
+
+- **Keep `O_TRUNC` + write in place** (matching the existing kube-file
+ primitive exactly): rejected — the kube-file primitive never needed true
+ atomicity, because nothing reads a kube-target file mid-write the way a
+ `file()` call racing a `run` restart against the *same pinned path*
+ (entry 14's mode 2, which did not exist for kube files pre-pivot) could.
+ The unified-output file's own delivery contract creates the race this
+ primitive was never exposed to before.
+- **Temp file + `os.replace()` (true atomic rename)**: **adopted** — create
+ the temp file in the same directory with `os.open(tmp, O_CREAT|O_WRONLY|
+ O_EXCL, 0o600)` (mode still fixed at creation, `O_EXCL` only guards a
+ colliding temp name), write the content, then `os.replace(tmp, final)` — a
+ single filesystem rename, atomic on the same filesystem, so a reader can
+ never observe a partial write.
+
+**Decision.** The writer combines both properties: mode-fixed-at-creation
+(inherited from the existing primitive's approach) **and** true atomicity
+(the `os.replace` step, which the existing primitive alone does not
+provide). **Process constraint, stated explicitly:** this writer runs in the
+CLI **parent** process (`run_command`), which holds no live `SessionDir`
+instance (kube materialization happens daemon/worker-side, inside the
+process that does own one) — the unified structure is a pure transformation
+of the already-complete `OutputSchema` the parent already has, so no daemon
+round-trip is needed to write it. `SessionDir._write_file` is reused
+directly only if refactored to be callable without a live instance;
+otherwise the primitive (not necessarily the same function object) is
+replicated inline in `cli.py`. The design doc's "reusing" language is
+corrected to reflect this either/or, not a flat claim of code reuse.
+
+**Consequences.** New code (`os.replace`-based atomic write), not a pure
+reuse of `SessionDir._write_file` as earlier text implied. The design doc's
+"Materialization write mechanism" subsection (new, iteration 6) and the
+plan's Task 5 carry the concrete implementation. `SessionDir._write_file`'s
+own description, wherever it appears, is corrected to drop "atomic" and say
+"mode-fixed-at-creation" instead — a real property, just not this one.
+
+**Stdin-mode guard, also recorded here.** A stdin-supplied `InputSchema`'s
+`daemon.materialize` is the caller's own explicit statement, and `start`
+(unlike `run`) does not force it true (`cli.py:160-174`). Under the
+now-unconditional `render_kube_env` call (entries 10/13), a declared but
+unmaterialized kube target (`path is None`) makes `render_kube_env` raise
+`ValueError` — existing, unchanged behaviour, but newly reachable from
+`start --output env`'s stdin-payload path since that call is now
+unconditional. The plan must guard this explicitly (force materialization
+for that path, or map the `ValueError` to a typed, user-facing error) rather
+than let it surface as a bare traceback.
+
+## 18. Relationship to #14: re-adopting fixes 1 and 4 for non-kube data, deferring fix 3
+
+> **[R16, iteration 7 — correction annotation.]** The "Decision" below
+> re-adopts #14 fix 1 (pin the session/state root) as an opt-in
+> precondition. **That re-adoption is retracted by iteration 7**: fix 1 is
+> no longer re-adopted in any form — the user's own constraint (session dir
+> stays mandatory *lifecycle* infrastructure, but is not a consumer-facing
+> pinning mechanism) settles this the way the ticket itself originally
+> argued. Fix 4 (materialized file + `file()`) is still re-adopted, but its
+> shape changes: located via the env-carried `TUNSTRAP_OUTPUT_FILE`, not a
+> pinned path. Fix 3's deferral is unaffected. Kept verbatim below per the
+> annotate-don't-rewrite discipline; see entry 19 for the superseding
+> decision.
+
+**Context.** Ticket #15 explicitly supersedes most of #14. This pivot's
+non-kube (port) delivery mechanism (entry 14, mode 2) is, in substance, two
+of #14's own original fixes — worth recording as a deliberate re-adoption,
+not silently reinventing a third mechanism that happens to look similar.
+
+**Alternatives considered.**
+
+- **Invent a new mechanism distinct from anything #14 proposed**: rejected —
+ there is no reason to; #14's fix 1 (pin the session/state root) and fix 4
+ (materialized file + `file()`) are exactly the plan-safe mechanism finding
+ #1 measures, and #15's own rejection of them was scoped to kube
+ specifically (an env-native alternative existed there), not to data in
+ general.
+- **Treat #14 fix 3 (warn on a saved-plan-capturing invocation, e.g.
+ `-out=`, while non-plan-safe delivery is in use) as in scope for #15**:
+ rejected — it is a genuinely separate feature (parsing the child's own
+ command line for Terraform-specific flags inside generic `run`), which the
+ pre-#15 design deliberately confined to `tunstrap_tofu` alone rather than
+ adding to `run` itself; taking it on here would re-litigate that
+ confinement as a side effect of an unrelated pivot.
+
+**Decision.**
+
+- **#14 fix 1** (pin the session/state root): re-adopted as an **opt-in
+ precondition** — a caller-supplied, stable `--session-dir` — not a
+ default. #15's rejection of pinning-by-default holds for kube (env-native
+ alternative exists) and does not extend to ports (no env-native
+ alternative exists at all).
+- **#14 fix 4** (materialized file + `file()`): re-adopted for the same
+ reason — the only plan-safe mechanism available once fix 1 provides a
+ stable path.
+- **#14 fix 3** (CLI warning on `-out=`-style saved-plan capture): **out of
+ scope for #15, deferred to #14.** The stability contract and the recipe's
+ explicit warnings cover the risk in documentation.
+
+**Consequences.** The design doc's "Relationship to #14" subsection (new,
+iteration 6) is the durable record a future #14 implementer reads before
+picking fix 3 back up; it also appears in "Out of scope" so an implementer
+of *this* ticket sees the deferral as deliberate, not as a gap.
+
+## 19. Unified env-native materialization contract: content on disk, paths in env — supersedes entries 14/18's pinned-path delivery and the pre-#15 fetch_files var-carriage decision
+
+**Context.** Iteration 7 is the user's own confirmed direction after the
+red-team round, plus one added constraint. Two things about the shipped
+iteration-6 design were unsatisfying on reflection: (1) entry 14's mode 2
+re-purchased plan-safety for ports by re-adopting #14 fix 1 (a pinned
+`--session-dir`) — exactly the mechanism ticket #15's own text explicitly
+rejected ("session root can stay ephemeral; only the path to the kubeconfig
+has to be stable, and that is supplied through the environment"), re-adopted
+anyway on the grounds that ports have no env-native alternative; (2) the
+pre-#15 design's decision to let `fetch_files[*].content_b64` ride
+`--output-var` unprojected, carried forward unexamined through every
+iteration of this design, put arbitrary remote file content into a
+Terraform variable — and therefore into any saved plan file — by default,
+for any consumer who bound `--output-var` at all, independent of whether
+they read `content_b64` themselves.
+
+**Alternatives considered.**
+
+- **Keep entry 14's three-mode design (mode 2's pinned path) as-is**:
+ rejected — the user's own instruction settles this directly, and on
+ reflection the pinned-path re-adoption was solving ports' plan-safety
+ problem by quietly reintroducing the exact mechanism the ticket rejected,
+ just relabelled as "opt-in." Retaining it would leave the design
+ permanently answering "does the ticket's rejection of fix 1 hold?" with
+ "yes, unless you want ports to be plan-safe," which is not an honest
+ reading of a rejection the ticket stated without that carve-out.
+- **Keep `fetch_files[*].content_b64` riding the var form inline, documented
+ with a warning** (the pre-#15 decision, carried through entries 1-18
+ unexamined): rejected — a warning is weaker than removing the exposure
+ entirely, and removing it is cheap: the daemon already owns the session
+ dir and already materializes kubeconfigs the same way (entry 3), so
+ extending the identical mechanism to fetched files is not new
+ infrastructure, only a new call site of infrastructure that already
+ exists and is already trusted for credential-bearing kube data.
+- **[Adopted, with the user's added constraint folded in.]** Content lives
+ on disk, under the (still-ephemeral) session dir, mode `0600`, via the
+ atomic-replace primitive (entry 17): kubeconfigs (already so, entry 3),
+ `output.json` (already so, entry 17), and now fetch_files. Env carries
+ only paths/locators — `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` (unchanged),
+ `TUNSTRAP_OUTPUT_FILE` generalized from a `start`-only bootstrap scalar
+ (entry 13) into the primary locator for `run` too, plus the session
+ scalars. `--output-var` survives, narrowed to its one genuinely remaining
+ job: a bridge for bare `tofu`, which has no `get_env(...)`-equivalent and
+ cannot read the process environment from HCL at all without a variable
+ binding. **User's added constraint, folded in rather than treated as a
+ separate decision**: the session dir itself does not become optional or
+ disappear just because it stops being a consumer-facing plan-safety
+ mechanism — it remains required *process lifecycle infrastructure*
+ (`daemon.pid`, `session.lock`, `stop`/recovery), a different concern from
+ whether a consumer's HCL may assume its path is stable across
+ invocations.
+
+**Decision.**
+
+1. Delivery collapses from entry 14's three modes to two: Mode A (kube
+ env-native, unchanged) and Mode B (`TUNSTRAP_OUTPUT_FILE` → the unified
+ manifest file, `get_env(...)`/`file()`/`jsondecode`/`try()`), with
+ `--output-var` as Mode B's narrow bare-`tofu` bridge, not an
+ independently-documented third mode.
+2. `fetch_files` entries in every consumer-facing channel become
+ `{path, size, sha256}` (or `{error}`), never `content_b64` — the daemon
+ materializes fetched bytes to `tunnel-data/-` (mode
+ `0600`, atomic replace, mirroring entry 3's kube materialization exactly).
+ `FetchedFile.content_b64` itself is not removed from the model — it stays
+ internal plumbing between the SSH fetch and the on-disk write, exactly as
+ `KubeTargetOutput.content_b64` already does for kube (entry 3); `start`'s
+ raw default JSON stdout is unaffected, per the existing scope carve-out.
+3. Entry 14's mode 2 (pinned `--session-dir` + literal HCL path) is dropped;
+ entry 18's re-adoption of #14 fix 1 is retracted (fix 4 survives,
+ reshaped to the env-carried locator).
+4. The session dir stays mandatory, ephemeral, lifecycle infrastructure —
+ this does not change; only its role as a *consumer-facing* mechanism is
+ retracted.
+
+**Consequences.** Plan-safety across a tunstrap restart for ports and
+`fetch_files` is gone — it existed only via the now-dropped pinned mode; a
+consumer needing it has no supported mechanism beyond re-running `plan`
+within the same tunstrap invocation. Kube's plan-safety is untouched (its
+env-native channel never depended on any of this). The "never `--fetch`
+secrets with `--output-var`" warning (entries carried since the pre-#15
+design) is resolved **as a class**: content no longer rides any
+consumer-facing channel, so the warning is retracted, not restated, in the
+recipe. Every `docs/recipe_terragrunt.md` example using a literal pinned
+path or `content_b64` breaks and is rewritten (design doc, "Documentation");
+every test asserting `content_b64` presence in a consumer-facing envelope
+(`--output-var`, materialized file) is retargeted to assert its absence and
+a `path` field instead — the plan's Task 5 blast-radius table carries the
+full enumeration. Entries 14 and 18 are annotated above, not rewritten, per
+this document's own established discipline (entry 14's own annotation of
+entry 11).
+
+## 20. `suppress_kubeconfig` narrowed: drop only the injected `KUBECONFIG`, never `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` — corrects entry 7
+
+**Context.** Issue #14 (a comment on the closed #15 handoff) reported that
+Mode A — the plan-safe, env-native kube delivery this design shipped, and the
+recipe's own documented pattern for it — cannot work through `tunstrap_tofu`,
+which the same recipe tells consumers to use as `terraform_binary`. Measured
+by the reporter: two nodes, one k3s target each, a real tunnel, `tofu`
+replaced by a stub printing its own environment. Through `tunstrap_tofu`
+(`suppress_kubeconfig=True`) all three of `KUBECONFIG`, `KUBE_CONFIG_PATH`,
+`KUBE_CONFIG_PATHS` came back unset; through plain `tunstrap run` the channel
+was correct (colon-joined paths, `KUBE_CONFIG_PATHS` plural for two files,
+contexts renamed to `tunstrap--`). A provider block following
+Mode A's own worked example (only `config_context` set, per the design doc's
+"Mode A" section item 1) therefore resolves no kubeconfig through the proxy
+and fails against the inert `localhost:80` loopback. Fails loudly, not
+silently — but Mode A is unusable through the one documented entry point for
+it.
+
+The root cause is entry 7's own reasoning, not an accident: entry 7
+deliberately widened `suppress_kubeconfig` from "drop `KUBECONFIG`" to "drop
+all three," reasoning that once this design started exporting
+`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` — the names the providers' own Go
+config chains actually read — the guard would become "load-bearing for that
+provider-native audience too, for the first time," and would be hollow if it
+kept exempting them. That reasoning inverts what the guard is for. Entry 7's
+own evidence (the provider findings) already established that providers never
+read plain `KUBECONFIG` at all; they read `KUBE_CONFIG_PATH`/
+`KUBE_CONFIG_PATHS` directly. Those two are not a *fallback* channel a broken
+`config_path` chain could silently reach through — for Mode A they are *the*
+channel, by design, reached deliberately and unconditionally by `run`,
+proxied or not. Suppressing them does not stop a silent wrong answer; it
+removes the only right answer Mode A has through `tunstrap_tofu`.
+
+**Alternatives considered.**
+
+- **Keep entry 7's decision as shipped (drop all three)**: rejected — directly
+ falsified by the measurement above; this is the defect being fixed, not a
+ live option.
+- **Pop-before-inject ordering** — drop the three names from the copied
+ parent environment, then call `render_kube_env`, and drop nothing
+ afterward: rejected. This produces the same broken result by a different
+ route: with `suppress_kubeconfig=True` and no post-injection pop, the
+ `render_kube_env` call happening *after* the pre-injection pop would still
+ place a fresh `KUBECONFIG` into the child env (single-file cardinality) —
+ restoring exactly the injected `KUBECONFIG` this guard exists to remove.
+ Ordering by itself does not narrow *which* names are protected; it only
+ changes *when* the removal happens, and the removal still has to be
+ keyed by name, not by inherited-vs-injected origin alone, to keep the
+ provider channel while dropping the `KUBECONFIG` fallback.
+- **[Adopted.]** Narrow the suppression by key, and split inherited vs
+ injected handling, as two independent rules:
+ 1. Always, on both the plain and the proxied path: drop *inherited*
+ `KUBECONFIG`/`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from the copied
+ parent environment **before** `render_kube_env` injects — a stray
+ operator environment must never contribute to the child's kube channel,
+ regardless of `suppress_kubeconfig`.
+ 2. Only when `suppress_kubeconfig` is set: drop the *injected* `KUBECONFIG`
+ afterward, keeping `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` intact.
+ `render_kube_env`'s own conditional-cardinality contract (entry 3) is
+ unchanged — one file exports `KUBECONFIG`+`KUBE_CONFIG_PATH`; two or
+ more export `KUBECONFIG`+`KUBE_CONFIG_PATHS`, never both PATH forms at
+ once.
+
+**Decision.** `suppress_kubeconfig` drops only the injected `KUBECONFIG`.
+Inherited `KUBECONFIG`/`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` are always
+dropped before injection, unconditionally on both paths — this part of entry
+7's original intent (never let an operator's ambient kubeconfig leak through)
+is preserved and, if anything, made more precise: it now also covers the
+plain `run` path, which entry 7 did not distinguish. The anti-fallback
+property this guard exists for — a broken `TF_VAR_tunstrap` → `config_path`
+chain (Mode B) must not silently reach the cluster through `KUBECONFIG` — is
+still exactly satisfied, since providers never read `KUBECONFIG` and the
+audience that does (`kubectl`/`helm` CLI invocations and `local-exec`
+provisioners inside `tofu`) is unaffected by keeping `KUBE_CONFIG_PATH`/
+`KUBE_CONFIG_PATHS` present.
+
+**Consequences.** `tunstrap/cli.py`'s `_build_child_env` gains an
+unconditional inherited-name scrub ahead of `render_kube_env`, and its
+`suppress_kubeconfig` block narrows from a three-name loop to a single
+`KUBECONFIG` pop; its docstring is rewritten to state this contract exactly,
+replacing the "drops all three, inherited and injected" claim entry 7
+introduced. `docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md`'s
+"Interaction with the tofu proxy's `suppress_kubeconfig`" subsection carries
+the same correction, in place, with its own falsified conclusion marked and
+kept for the historical record rather than deleted.
+`docs/recipe_terragrunt.md` is corrected in both directions: the "How the
+proxy works" section states plainly that only `KUBECONFIG` is suppressed, and
+the Mode A section states plainly that Mode A works through `tunstrap_tofu`.
+Four properties are pinned by new or retargeted unit tests in
+`tests/unit/test_cli_run_output_var.py` and `tests/unit/test_tofu_proxy.py`:
+the proxy path keeps the provider channel; the proxy path drops the injected
+`KUBECONFIG`; the plain path keeps the full, unfiltered channel; and an
+inherited value of all three is dropped on both paths, including when there
+are no kube targets at all to inject a replacement over it.
diff --git a/docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md b/docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md
new file mode 100644
index 0000000..f033bae
--- /dev/null
+++ b/docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md
@@ -0,0 +1,1532 @@
+# Kubeconfig-as-identity delivery: deterministic contexts + multi-node kube channel
+
+> **Redaction/repoint note (2026-08-10):** Repointed provider evidence to its
+> committed spec, replaced a local worktree path with a placeholder, and marked
+> finding #4 as an unpublished-spike measurement because no automated test
+> covers its saved-plan mutation scenario.
+
+- Status: design, awaiting review
+- Date: 2026-08-07 (revised same day, iteration 3: the unified-output-contract
+ pivot, marked **[PIVOT]** at each affected section below)
+- Scope: **[kube part, unchanged by the pivot]** rename the materialized
+ kubeconfig's cluster/user/context identities deterministically per
+ `(node, target)`; export the OpenTofu-provider-facing kube env vars
+ (`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`) conditionally on how many
+ kubeconfig files were materialized. **[PIVOT, iteration 3]** Replace
+ tunstrap's *entire* consumer-facing output — ports, kube references,
+ session metadata — with one unified, node-qualified structure, delivered
+ both as an `--output-var` value and as a materialized JSON file, entirely
+ superseding the flat `TUNSTRAP__*` scalar channel (which is
+ removed, not extended with a node dimension). Document the one recipe both
+ parts enable. Ticket: [AlexMKX/tunstrap#15](https://github.com/AlexMKX/tunstrap/issues/15)
+ (handoff, supersedes most of #14). Target branch: `feature/run-env-io`
+ (PR #13).
+- Measurement basis: the ticket's own six OpenTofu v1.12.5 findings (2026-08-06/
+ 07, **not re-derived here**); the provider-behaviour verification in
+ `docs/specs/2026-08-10-issue15-provider-env-precedence.md` (OpenTofu v1.12.5,
+ `hashicorp/kubernetes` v2.38.0, `hashicorp/helm` v2.17.0, live-probed against
+ a real `kind` cluster, 2026-08-07); and untracked implementation-spike notes
+ (six prototype variants, each run against the full `tests/unit` suite,
+ 2026-08-07). The provider result is repeated in the committed spec.
+- Code citations (`kube.py:NNN`, `envrender.py:NNN`, `cli.py:NNN`) are against
+ `e5ed15d`, the tip of `feature/run-env-io` at the time of writing (== the
+ spike's `spike/issue15-variants` base commit).
+- Reference implementation: `variant/combined` in the scratch worktree
+ `` — reviewed,
+ 475/475 pre-existing unit tests pass plus one new regression test (476/476).
+ **Cherry-pick precisely, not wholesale — corrected iteration 6.** The spike
+ is safe to cherry-pick for exactly two things: `rename_identities` (V1c)
+ and the `render_kube_env` split (Axis 2). **Its Axis 3 (the unconditional
+ superset env export) is *rejected*, not adopted** — an earlier revision of
+ this note credited the spike with "the conditional cardinality contract,"
+ which is false: the spike never prototyped the conditional contract this
+ design actually ships (that is a post-spike, iteration-1 design decision,
+ built *in reaction to* the spike's Axis 3 and the provider findings, not
+ cherry-picked from it). Do not copy the spike's `render_kube_env` body
+ verbatim for its env-export tail; only the node-count-agnostic path
+ collection is reusable, the export-key selection is not. **`cli.py`
+ wiring was never in the spike at all** — the spike's own findings document
+ raised it only as an open question (open question 1: whether wiring
+ `render_kube_env` into `run` was in scope), never as a prototyped variant;
+ every line of `_build_child_env`'s wiring in this design and the plan is
+ new work, not a cherry-pick. **Scope note, otherwise unchanged: the spike
+ predates the iteration-3 pivot and covers the kube part only** — nothing
+ in it prototypes the unified output contract below, and the spike's
+ `render_env`-delegation mechanism for wiring `render_kube_env` into `run`
+ (iteration 2's two-branch `_build_child_env`, itself new work built after
+ the spike, not from it) is superseded by a simpler unconditional call once
+ the scalar channel it branched around is removed — see "The unified
+ output contract" and the plan's Task 5.
+
+## Problem
+
+Connection data currently travels through two channels that were never
+designed for what the ticket calls the actual shape of the domain:
+
+1. **Terraform input variables**, which OpenTofu persists into the plan file —
+ any live value bound there (a private key, a materialized kubeconfig)
+ becomes durable, pipeline-archived state (design spec
+ `2026-07-31-run-env-io-and-tofu-proxy-design.md`, `RunKubeTarget`'s
+ allow-list rationale).
+2. **The `TUNSTRAP_*` scalar channel**, which has no node dimension:
+ `render_env` raises `MultiNodeEnvUnsupported` outright for `len(nodes) != 1`
+ (`envrender.py:26-30`), because `TUNSTRAP__*` has no way to
+ disambiguate two nodes sharing a target name. **[PIVOT, iteration 3]** The
+ fix adopted below is not "add a node dimension to the scalars" — a flat
+ `KEY=VALUE` shape has no natural place to put one without inventing a
+ second encoding scheme inside the key name. The scalar channel is instead
+ **removed outright** and replaced by a structure whose node dimension is
+ just a normal nested key, because that is what a node dimension actually
+ is. See "The unified output contract" below.
+
+Two framing corrections drive the fix. The bolded lead phrase in each bullet
+is close to the ticket's own words; **the explanatory sentence after it is
+this design's own re-reading, not a ticket quotation** — an earlier revision
+of this section claimed both bullets were "from the ticket verbatim" in
+full, which overstated how much of the surrounding prose is actually the
+ticket's own, corrected here:
+
+- **Multi-node is the base path, not an edge case.** [Design's own
+ elaboration, not the ticket's words:] the single-node assumption in
+ `render_env` was never a deliberate design choice — it is an artefact of
+ the scalar channel's own limitation, wrongly generalized to the whole kube
+ delivery mechanism.
+- **Kubeconfig contexts are the natural addressing mechanism, and tunstrap was
+ not using them.** [Design's own elaboration:] a kubeconfig set with one
+ context per target addresses any number of nodes/targets and has no
+ node-dimension problem — *if* the context
+ names are actually distinct. Today they are not: `kube.py:99` sets
+ `context_name=current` from the source document's own `current-context`
+ verbatim, and `dump_kubeconfig` serializes that same document unchanged
+ (see "Correction to the ticket text" below for the precise division of
+ labour). Two k3s targets — which both ship `current-context: default` — thus
+ collide irreducibly on merge; this is the expected case, not an edge case
+ (see "The collision trap", below).
+
+The same defect exists beyond OpenTofu: `kubectl --context`, helmfile, ArgoCD
+and anything else consuming a materialized kubeconfig sees whatever name the
+upstream cluster happened to pick. This is a general contract fix to the
+materialized kubeconfig's identity, not a Terraform patch — the env-export
+contract (below) is the one part that is Terraform/OpenTofu-shaped, because it
+exists to feed a provider.
+
+## The deterministic-naming contract
+
+Naming scheme for **context, cluster and user alike**:
+
+```
+tunstrap--
+```
+
+- **NOT unique by construction — a real collision surface, closed by an
+ explicit check [R10, corrected iteration 6].** An earlier revision of this
+ design claimed the join was unique because `node`/`target` are validated
+ identifiers. That claim is false: `_FETCH_FILES_KEY_RE`
+ (`schemas.py:11`, `^[a-zA-Z_][a-zA-Z0-9_-]*$`) permits internal hyphens, and
+ the render itself joins with a hyphen (`tunstrap--`), so two
+ **different** `(node, target)` pairs can render the **identical** string:
+ `(node="a-b", target="c")` and `(node="a", target="b-c")` both produce
+ `tunstrap-a-b-c`. This is a real, not theoretical, collision, and the
+ mandatory k3s-style regression test (below) does **not** cover it — that
+ test proves *upstream*-name collisions are fixed by the rename; this is a
+ different defect class entirely (tunstrap's *own* naming scheme colliding
+ with itself, upstream names never entering the picture). **Fix: a
+ validation-time collision check** across every `(node, target)` pair in the
+ whole payload (all nodes × each node's `kube_targets`), computed at schema
+ validation — before any SSH connection is attempted — rejecting the
+ payload with an error naming the exact colliding pairs if any two joined
+ names coincide. The hyphen join itself is kept (changing the separator is
+ a larger, unrequested change); the fix detects and rejects the collision
+ rather than structurally preventing it.
+- **Node-qualified**, so it stays unique across multiple nodes *for a given
+ join*, closing the exact gap `render_env`'s node-blindness left open — this
+ property is real and unaffected by the join-collision defect above, which
+ is about two *different* joins coinciding, not about the node dimension
+ itself being absent.
+- **Fixed `tunstrap-` prefix**, giving tunstrap's own contexts **conventional,
+ probabilistic namespacing** against whatever else a consumer's kubeconfig
+ set already carries — not a guarantee. **Residual risk, documented:** a
+ consumer-owned context already literally named `tunstrap--`
+ for the same `(node, target)` pair collides on merge (accepted — an
+ operator choosing that exact name is choosing to alias tunstrap's own
+ scheme); two **independent** tunstrap runs whose node/target names happen
+ to coincide also collide (accepted per the no-configurable-prefix decision
+ below — the node name is assumed unique *within* one run's payload, not
+ across unrelated runs an operator chooses to merge).
+- **No configurable prefix.** Org rule: avoid excessive configurability. The
+ node name already solves the "merge two separate tunstrap runs" scenario a
+ configurable prefix would otherwise be reached for, for the common case
+ where the operator controls both runs' node names; it does not solve two
+ runs an operator merges without also controlling their node-naming
+ overlap, which is the residual risk stated above.
+
+All three identity strings (cluster name, user name, context name) get the
+**same** rendered value — there is no reason for them to diverge, and a single
+shared name is what a `kubectl config get-contexts` or `--context` invocation
+actually needs to be unambiguous.
+
+### Rename scope: the active triple only
+
+Only the entries the current-context actually resolves to are renamed —
+**but every reference to them must be updated, including references that
+live inside otherwise-ignored entries [R14, corrected iteration 6]:**
+
+- the `contexts[]` entry named `doc["current-context"]`, plus its
+ `context.cluster` / `context.user` references;
+- the `clusters[]` entry that reference resolves to;
+- the `users[]` entry that reference resolves to;
+- `doc["current-context"]` itself;
+- **any *other* `contexts[]` entry (one already reported via
+ `ignored_contexts`, since it is not the current context) whose own
+ `context.cluster` or `context.user` happens to reference the *same*
+ cluster/user name being renamed** — a kubeconfig can legitimately have two
+ contexts sharing one cluster or user entry (e.g. two contexts against the
+ same cluster with different users). Leaving such a reference unrenamed
+ while the entry it points at *is* renamed produces a dangling reference:
+ the ignored context would name a cluster/user that no longer exists under
+ that name anywhere in the document, which is strictly worse than the
+ pre-rename state (a mis-typed context that fails immediately if selected,
+ rather than an odd-but-working one).
+
+**Every entry that is neither part of nor referencing the active triple
+remains byte-stable, unrenamed** — a narrower, correct claim than an earlier
+revision's "every other context/cluster/user... is left byte-stable," which
+did not distinguish "genuinely unrelated to the active triple" from
+"unrelated as far as being the *current* context, but still pointing at the
+same cluster/user by name." The warning for skipped contexts is emitted in
+`run_kube_targets` (`kube.py:330-337`, not `parse_kubeconfig`'s
+`_ignored_contexts` collection helper at `kube.py:179-183`, which only
+*computes* the list — the warning itself is logged where that list is
+consumed). This matches the module's pre-existing, documented contract:
+*"One kube_target maps to exactly one cluster: the kubeconfig's
+current-context. Other contexts/clusters are ignored and left byte-stable in
+the patched output"* (`kube.py:1-8`) — read now as "left byte-stable" meaning
+"not independently re-targeted," not "guaranteed to still reference their
+original names once a shared entry is renamed." The rename does not change
+that contract's scope, it only fixes the one triple tunstrap already claims
+ownership of, correctly this time.
+
+**Accepted residual risk, documented, not solved here:** if an upstream
+kubeconfig carries *other* (non-current) contexts that also collide across two
+materialized files, that merge exposure is not addressed by this change. This
+is accepted under the ticket's own "one cluster per kube_target" framing, and
+is the normal case in practice — k3s and kind both ship single-context
+kubeconfigs. If it becomes a real problem, the fix is pruning ignored entries
+entirely at materialization time, which is a larger, separate change (see
+"Open questions" in the spike findings).
+
+### Where the rename happens
+
+`rename_identities(doc, node, target) -> str` — a standalone **deterministic
+in-place transformation** in `tunstrap/kube.py` (not a pure function in the
+strict sense: it mutates `doc`, the same ruamel document `parse_kubeconfig`
+returned, rather than returning a new one — "standalone" and "deterministic"
+are the properties that actually matter here, not side-effect-freedom). It
+operates on the raw parsed document alone: it resolves
+`doc["current-context"]` itself, so it needs no `KubeconfigView` and can be
+unit-tested with a bare dict, independent of `parse_kubeconfig`,
+`run_kube_targets`, or any SSH-driven orchestration. It returns the new name
+(shared by cluster, user and context), and the caller — `run_kube_targets`,
+between `patch_view` and `dump_kubeconfig` — updates `KubeTargetOutput` from
+that return value.
+
+This was one of three placements prototyped and measured (spike findings,
+"Part 2 — variant comparison"; ADR entry "Rename placement"); the alternatives
+(rename inline inside `run_kube_targets` mutating `KubeconfigView` in place; or
+rename as a side effect of `dump_kubeconfig`) both work with zero test
+breakage too, but neither is independently unit-testable without going
+through the fuller orchestration, and the `dump_kubeconfig` placement couples
+"serialize to bytes" with "mutate identity" — see the correction note below
+for why that coupling is specifically worth avoiding here.
+
+### Correction to the ticket text
+
+The ticket states: *"`dump_kubeconfig` serialises that same document with only
+the server address patched."* This describes the **combined effect** of
+`patch_view` followed by `dump_kubeconfig` in sequence, not a responsibility of
+`dump_kubeconfig` itself. Precisely:
+
+- `patch_view` (`kube.py:243-270`) is what rewrites `server:`, sets
+ `tls-server-name` or the insecure pair — on the current-context cluster
+ only.
+- `dump_kubeconfig` (`kube.py:273-277`) is a **pure serializer**: it does not
+ patch anything, it YAML-dumps `view.doc` exactly as it finds it.
+
+This spec's rename call site sits between the two (`patch_view` → *rename* →
+`dump_kubeconfig`), and `dump_kubeconfig` gains no new responsibility — it
+stays a pure serializer, which is also why the rename is a standalone function
+rather than a `dump_kubeconfig` parameter (the placement the spike prototyped
+and rejected for exactly this reason).
+
+## The unified output contract [PIVOT, iteration 3]
+
+**This section is the overarching decision this design was revised around; it
+supersedes the "scalar channel stays single-node" framing everywhere else in
+this document.** User decision (encoded, not re-litigated here; ADR entries
+10-13 carry the alternatives-considered detail).
+
+### Shape
+
+The entire consumer-facing output — ports, kube references, session metadata
+— is one JSON structure, node-qualified by construction (the node dimension
+is a nested key, not an encoding problem):
+
+```json
+{
+ "session": {
+ "session_dir": "/run/tunstrap/abc123",
+ "pid": 4711,
+ "started_at": "2026-08-07T00:00:00Z",
+ "warnings": []
+ },
+ "nodes": {
+ "node1": {
+ "ports": {
+ "service1": "127.0.0.1:5432"
+ },
+ "kube": {
+ "k3s": {
+ "path": "/run/tunstrap/abc123/tunnel-data/node1-k3s",
+ "context": "tunstrap-node1-k3s",
+ "endpoint": "https://127.0.0.1:41111"
+ }
+ },
+ "fetch_files": {
+ "hosts": {"path": "/run/tunstrap/abc123/tunnel-data/node1-hosts", "size": 6, "sha256": "..."}
+ }
+ }
+ }
+}
+```
+
+- **Ports**: a plain `"host:port"` string per target — the minimal,
+ consumer-friendly shape the user's own sketch names (`node1 { service1:
+ hostport }`).
+- **Kube**: a small object per kube target — `{path, context, endpoint}` —
+ never credentials, never file content (U4; the existing
+ `RunKubeTarget`/`KubeTargetOutput` credential-scrubbing already established
+ in the pre-#15 design is preserved, just reshaped). `context` is the
+ post-rename `tunstrap--` name from the kube part above, so a
+ consumer that wants to address the cluster by context rather than by
+ `config_path` can (`kubectl --context "$(jq -r ...)"`, or a provider's
+ `config_context` field with `config_path` also set).
+- **`fetch_files`** — **[R16, supersedes this bullet's pre-iteration-7 text,
+ which is retracted, not extended]** no longer rides through unprojected.
+ The pre-#15 design's own choice to let `content_b64` ride the var form
+ verbatim (`docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`,
+ decision history's `fetch_files[*].content_b64` entry) is superseded: R16's
+ core principle — *content on disk, paths in env* — extends to fetched files
+ the same way it already applied to kubeconfigs. The daemon (which already
+ owns the session dir and already materializes kube files there) writes
+ each fetched file's bytes to `tunnel-data/-` (mode `0600`,
+ the same atomic-replace primitive as "Materialization write mechanism"
+ below) and the consumer-facing entry is exactly `{path, size, sha256}` — no
+ `content_b64` anywhere in it, mirroring `UnifiedKubeRef`'s own
+ `{path, context, endpoint}` narrowing (U4, above) rather than being a new
+ pattern. A failed fetch still projects `{"error": "..."}`, unchanged. See
+ "Fetched-file materialization [R16, new]" below for the mechanism and
+ "Compatibility" for why this is a breaking change stated plainly, not a
+ silent narrowing.
+
+**Judgment call, not literally specified by the user's sketch:** the root
+object has exactly two reserved top-level keys, `session` and `nodes`, rather
+than putting node names directly at the document root. A flat root (node
+names as literal top-level keys, matching the sketch most literally) was
+considered and rejected: `node` names are operator-controlled identifiers up
+to 64 characters matching `^[a-zA-Z_][a-zA-Z0-9_-]*$` (`schemas.py:14-22`) —
+an operator is free to name a node `session` or `nodes`, which would collide
+with the reserved top-level keys themselves in a flat root, with no
+validation catching it (a node literally named `warnings` would only collide
+if `warnings` were also hoisted to the document root — it is not, in the
+adopted shape, since `warnings` lives nested under `session`; the flat-root
+collision example is `session`/`nodes` colliding with themselves, not
+`warnings`). Two reserved keys eliminate that collision by construction, at
+the cost of one extra nesting level from the literal sketch. `session_dir`
+and `pid` are kept inside `session`, alongside `warnings`, purely for
+grouping and symmetry with the rest of the document (every piece of
+non-node-scoped metadata lives in one place) — not for a second collision
+reason, since neither `session_dir` nor `pid` would themselves collide with
+anything at a bare top level.
+
+### Delivery: two independent modes [R16, iteration 7 — supersedes R9's three-mode design]
+
+**[R16] Iteration 7's user-confirmed direction retracts R9's mode 2 (the
+literal-pinned-`--session-dir` file), collapsing delivery from three modes to
+two.** R9's own reasoning for modes 1 and 3 below is unchanged and restated
+here, not re-litigated; only mode 2 is gone, and `TUNSTRAP_OUTPUT_FILE`
+(previously a `start`-only bootstrapping scalar, see "The scalar channel is
+removed" below) is generalized into mode 2's replacement — the **primary**,
+env-carried locator for the unified manifest, for `run` as well as `start
+--output env`. This is the direction the user confirmed after the red-team
+round: **content lives on disk under the (ephemeral) session dir, only paths
+travel through the environment** — never a pinned, operator-chosen root the
+consumer's HCL has to independently know in advance.
+
+R9's original three-mode framing (superseded by the two-mode list below —
+not reproduced verbatim here; ADR entry 14 carries the original text so the
+retraction reads as a decision, not a silent deletion) is retracted for the
+reason the user's own instruction states directly: ticket #15 explicitly
+rejected #14 fix 1 ("session root can stay
+ephemeral; only the path to the kubeconfig has to be stable, and that is
+supplied through the environment") — R9's mode 2 **was** fix 1, re-adopted
+for ports against the ticket's own explicit rejection, on the grounds that
+ports have no provider-native env path the way kube does. Iteration 7
+retracts that re-adoption instead of continuing to defend it: root stays
+ephemeral, unconditionally, and ports' plan-safety story is a genuine loss
+(see "Stability contract," "what is lost," below) rather than being
+purchased via a pinned path. See "Relationship to #14 [R15]" below for the
+corrected, no-longer-re-adopting-fix-1 text, and ADR entries 14/18/19 in the
+decision history.
+
+**Iteration 4's design is retracted below, not extended.** It treated the
+unified structure as delivered by one hybrid mechanism — inject `var.tunstrap`
+and *also* materialize a file, with the recommended plan-safe pattern being
+"read `var.tunstrap` only to locate the file, then `file()` the located
+path." A three-model red-team review found this unsound for three
+independent, compounding reasons (findings #1/#5):
+
+1. **Finding #1 measured content-change tolerance at a *stable* path.** The
+ locator pattern instead put a *changing* value — `var.tunstrap`, whose
+ full JSON differs on every invocation (fresh `pid`, `started_at`, ephemeral
+ local ports, a fresh kube `path`) — into a Terraform variable and called
+ that "safe" because only one field of it (`session.session_dir`) happened
+ to be read. That does not help: OpenTofu's plan-variable consistency check
+ (finding #6) compares the **whole bound value** of a root-module variable
+ between `plan` time and `apply` time, not just the sub-fields an
+ expression happens to reference. A locator built from `var.tunstrap` trips
+ finding #6 on any saved-plan reuse exactly as readily as binding the ports
+ directly would — the indirection buys nothing.
+2. **Finding #6 fires on the *whole* variable, confirmed by (1).**
+3. **The default session directory does not survive to be located.** Without
+ a caller-supplied `--session-dir`, `run` auto-mints an ephemeral root via
+ `tempfile.mkdtemp` (`cli.py:427`, `_mint_session_dir`) and teardown deletes
+ it. Even setting aside (1)/(2) entirely, a locator pointing at that default
+ root would usually be pointing at a directory that no longer exists by the
+ time a later, separate `apply` invocation tried to read it.
+
+**[R16] Revised contract: two genuinely independent delivery modes.** Mode
+1 is unchanged from R9. Mode 2 replaces R9's modes 2 and 3 combined: the
+env-carried `TUNSTRAP_OUTPUT_FILE` locator (a real env var, not a Terraform
+variable) is now the *only* way a consumer reaches the unified manifest, for
+both a plain-shell reader and an HCL one — `--output-var` survives only as a
+narrower fallback for a caller that cannot read the process environment at
+all (bare `tofu`, see below), not as a second, independently-named mode:
+
+1. **Kube env channel** (`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`) — unchanged,
+ primary for kube, plan-safe per findings #1/#3 (see "Env-export contract"
+ below), no variable and no file read anywhere in the path at all.
+2. **`TUNSTRAP_OUTPUT_FILE` → the unified manifest file** — the session dir
+ stays ephemeral, unconditionally (no `--session-dir` precondition, no
+ caller-pinned path); `run` (and `start --output env`) exports
+ `TUNSTRAP_OUTPUT_FILE=/tunnel-data/output.json` as a plain
+ process env var and the consumer reads it via `get_env(...)` (Terragrunt)
+ or `os.environ[...]` (a plain shell/Python child) — never a literal path
+ baked into the consumer's own config, because the path is fresh, ephemeral,
+ and different on every invocation by design. This is **one-shot,
+ `plan && apply` within the same tunstrap invocation only** — the same
+ restriction R9's mode 3 stated for the var form, now stated for the file
+ form too, because the file itself is deleted at `stop`/teardown alongside
+ the rest of `tunnel-data/` (see "session dir as lifecycle infrastructure,"
+ below): there is nothing left to `file()` on a later, separate `apply`
+ against a saved plan from a prior `run`. Ports and `fetch_files` have no
+ plan-safe-across-restart story left at all — the honest loss R9's mode 2
+ used to paper over (see "Stability contract," "what is lost").
+ ```hcl
+ locals {
+ tunnel = try(
+ jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))),
+ { nodes = {} },
+ )
+ }
+ ```
+3. **`--output-var NAME` (var form) — bridge for bare `tofu` only**, which
+ cannot read `get_env(...)` (a Terragrunt function) or, for that matter,
+ any process env var directly inside HCL at all without a variable
+ binding. Same one-shot restriction as mode 2, for the same reason
+ (finding #6: OpenTofu's plan-variable consistency check compares the
+ variable's whole bound value between `plan` and `apply`, and there is no
+ locator exemption — reading only a sub-field does not narrow the
+ exposure). Carries the lightweight manifest described in "The scalar
+ channel is removed," below — ports as `host:port` strings, kube as
+ `{path, context, endpoint}`, fetch_files as `{path, size, sha256}`, no
+ `content_b64` anywhere (R16).
+
+**No variable, anywhere, locates the materialized file.** `TUNSTRAP_OUTPUT_FILE`
+is a plain env var, read by `get_env(...)`/`os.environ`, never by decoding
+`var.tunstrap`/`TF_VAR_tunstrap` to extract a path — that indirection is
+exactly what R9's finding #1 analysis (above) showed does not help, and
+nothing about R16 changes that specific finding.
+
+### Materialization write mechanism [R13, corrected iteration 6]
+
+The mode-2 file (`/tunnel-data/output.json`, mode `0600`,
+cleaned up on `stop`/atexit alongside the kube materialized files — same
+directory, same lifecycle, since `fetch_files` content can carry arbitrary
+remote file content and deserves the same handling) must be written as a
+**true atomic replace**, not merely mode-fixed-at-creation:
+
+- Create a temp file in the same directory with
+ `os.open(tmp_path, O_CREAT | O_WRONLY | O_EXCL, 0o600)` (the `O_EXCL`
+ guards against a colliding temp name, not a security property — the mode
+ is already fixed at creation, as with the existing kube-file primitive).
+- Write the full JSON content to it.
+- `os.replace(tmp_path, final_path)` — a single filesystem rename, atomic on
+ the same filesystem, so a reader can never observe a partially-written
+ `output.json`. **`O_TRUNC` + write in place, used by the existing kube-file
+ primitive, is *not* atomic** — a reader can observe a
+ truncated-but-not-yet-rewritten file mid-write. **[R16, iteration 8 —
+ rationale re-grounded]** An earlier revision justified this by a consumer's
+ `file()` call racing a `run` restart that rewrites the *same pinned path* —
+ that race no longer exists under R16 (there is no pinned path; mode 2's
+ `TUNSTRAP_OUTPUT_FILE` names a fresh, per-invocation ephemeral path, and the
+ write completes before the child is even spawned, so nothing can be reading
+ the file concurrently with this process writing it under the current
+ design). The requirement **stays** — it is strictly safer than `O_TRUNC`
+ and costs nothing — but on grounds that do not depend on a race the design
+ no longer has: (1) **torn-read prevention on crash mid-write** — if the
+ writing process is killed between opening and finishing the write, `O_TRUNC`
+ leaves a truncated file at the final path with no signal anything is wrong,
+ where `os.replace` leaves either the old complete file or the new complete
+ one, never a partial one; (2) **defense-in-depth** against any future
+ change that reintroduces a stable/reusable path (this design already
+ retracted one such mechanism once, R16's own retraction of R9's mode 2 —
+ the atomic-replace property should not need re-deriving if that ever
+ happens again); (3) **consistency between writers** — every `run` mints a
+ fresh, ephemeral session dir (`tempfile.mkdtemp`), so in the current design
+ no two invocations' `tunnel-data/` paths ever collide and the fetched-file
+ writer (below) has exactly the same no-current-race property `output.json`
+ does, for the same reason. Sharing one atomic-replace primitive between both
+ writers is a maintainability argument, not a second race-prevention
+ argument dressed up as one: one primitive to reason about instead of two,
+ and reason (1) above (crash mid-write) applies identically to both. This is
+ new work, not present in the primitive this design otherwise reuses.
+- **Process constraint, stated explicitly:** this writer runs in the CLI
+ **parent** process (`run_command`, `cli.py`), which holds **no
+ `SessionDir` instance** — materialization of the kube files happens
+ worker/daemon-side, inside the process that already owns a `SessionDir`,
+ but the unified structure is a pure transformation of the already-complete
+ `OutputSchema` the parent already has, so writing it parent-side needs no
+ daemon round-trip. Reusing `SessionDir._write_file` (`session.py:132`)
+ directly is only possible if it is refactored into something callable
+ without a live `SessionDir` instance (or the parent is given one purely for
+ this write, which the design does not otherwise need); if that refactor is
+ not straightforward, the primitive above is replicated inline in `cli.py`
+ instead — the plan permits either, this spec's "reusing" language should be
+ read as "reusing the *primitive* (atomic, mode-fixed-at-creation write)," not
+ necessarily reusing the *same function object* — an earlier revision's flat
+ claim of reuse did not make this distinction.
+- **`SessionDir._write_file`'s own real property, restated precisely:** it is
+ **mode-fixed-at-creation** (`os.open(..., O_CREAT|O_WRONLY|O_TRUNC, 0o600)`
+ — no separate `chmod`, no window of broader permissions), **not
+ "atomic"** in the sense that matters here (`O_TRUNC` overwrites in place,
+ visible mid-write to a concurrent reader) — an earlier revision described
+ it as "the atomic-secure-write primitive," which conflated the two
+ properties. This design's materialization write needs *both*
+ mode-fixed-at-creation (still true of the temp file above) *and* true
+ atomicity (the `os.replace` step, which `_write_file` alone does not
+ provide). **[R16, iteration 8 — rationale re-grounded]** The kube-file
+ primitive's own gap was originally justified by a `file()` call racing a
+ `run` restart against the *same pinned path* — that specific race is
+ retired under R16 (no pinned path survives; see the note above this list).
+ The gap is real for a different, still-live reason: torn-read prevention if
+ the writing process is killed mid-write (a truncated-but-not-rewritten file
+ at the final path is indistinguishable from a valid empty/short one to a
+ naive reader, where `os.replace` guarantees the reader only ever sees a
+ complete old or complete new file), plus defense-in-depth against any
+ future change that reintroduces a stable/reusable path. `output.json`'s
+ writer needs both properties for the same reasons stated there, not because
+ of the retired race.
+- **Stdin-mode guard, flagged not silently assumed:** a stdin-supplied
+ `InputSchema` payload's `daemon.materialize` is the caller's own explicit
+ statement and `start` (unlike `run`) leaves it alone rather than forcing it
+ true (`cli.py:160-174`, `"a stdin payload's daemon.materialize is the
+ caller's own statement and is left alone"`). Under the now-unconditional
+ `render_kube_env` call, a kube target that was declared but never
+ materialized (`materialize: false` in the stdin payload) has `path is
+ None`, and `render_kube_env` raises `ValueError` for exactly that case
+ (`envrender.py`, existing behaviour, unchanged by this design). This is
+ fine for `run` (which always forces `materialize = True`, so the case
+ cannot occur), but `start`'s `--output env` path can reach it with an
+ operator-supplied stdin payload that explicitly disables materialization
+ while still declaring `kube_targets` — the plan must guard this
+ (materialization forced or the `ValueError` mapped to a typed,
+ user-facing error) rather than let an unconditional call surface a bare
+ `ValueError` traceback.
+
+### Fetched-file materialization [R16, new]
+
+Mirrors the kube-file precedent exactly, not a new pattern: `FetchedFile`
+(`schemas.py:292-313`) gains a `path: str | None = None` field, the same
+shape `KubeTargetOutput.path` already has alongside its own `content_b64`
+(`schemas.py:317-336`). The daemon-side step that already materializes kube
+files (worker/daemon process, holds a live `SessionDir`) gains a parallel
+step: for each successful `FetchedFile` a node's `fetch_files` produced,
+base64-decode `content_b64` and write the raw bytes to
+`tunnel-data/-` (mode `0600`, the same atomic-replace
+primitive as `output.json`'s writer — temp file + `O_EXCL` + `os.replace`,
+not `_write_file`'s mode-fixed-but-not-atomic `O_TRUNC`, for the same reason:
+a consumer's `file()` call inside a provider/data block could race a `run`
+restart rewriting the same node-qualified filename), then set `.path`
+accordingly. A failed fetch (`FetchedFile.error` set) materializes nothing
+and projects `{"error": ...}` unchanged. `content_b64` itself is **not**
+removed from the `FetchedFile` model — it stays as internal daemon-side
+plumbing between the SSH fetch and the on-disk write, exactly as
+`KubeTargetOutput.content_b64` already does for kube; only the
+**consumer-facing projection** (`render_unified_output`'s `fetch_files` entry,
+and by extension the materialized file's and `--output-var`'s content) drops
+it, per U4's already-established narrowing pattern for kube. `start`'s raw
+default JSON stdout (the "complete envelope," unchanged scope per
+"Compatibility," below) continues to show both `content_b64` and `path` on
+`FetchedFile`, exactly as it already shows both on `KubeTargetOutput` today —
+no new carve-out, the existing one already covers this symmetrically.
+
+### Session dir: ephemeral, but not optional — lifecycle infrastructure [R16, user constraint]
+
+**Stated explicitly per the user's own constraint, not left implicit.**
+Retracting R9's mode 2 (above) removes the session dir's role as a
+*consumer-facing* plan-safety mechanism, but the session dir itself does not
+disappear and does not become any less mandatory. It remains required
+**process lifecycle infrastructure**, unrelated to Terraform/consumer
+concerns: `daemon.pid`, `session.lock`, and everything `tunstrap stop
+--session-dir ` and crash-recovery depend on to find and signal a
+running daemon. Nothing about R16 changes any of that — `TUNSTRAP_SESSION_DIR`
+and `TUNSTRAP_PID` stay exported exactly as before (they are session
+*lifecycle* metadata, not a consumer-facing locator repurposed by R16), and
+the existing `stop`/recovery guidance is unaffected. The only thing that
+changed is what a *consumer's HCL* is allowed to assume about the directory's
+path being stable across invocations — nothing changed about tunstrap's own
+internal need for the directory to exist and be addressable while a session
+is live.
+
+### Stability contract (explicit) [R16, iteration 7 — supersedes R9's version]
+
+- **Kube env channel: always plan-safe, unconditionally.** No caveat — no
+ variable, no file, findings #1/#3. Unchanged by R16.
+- **`TUNSTRAP_OUTPUT_FILE` (mode 2) and `--output-var` (mode 3): both
+ one-shot `plan && apply` only, unconditionally.** No saved-plan reuse
+ across a tunstrap restart for either — the file is deleted at
+ teardown/`stop` alongside the rest of `tunnel-data/` (the session dir stays
+ ephemeral, per the subsection above), and the var form was already
+ one-shot-only under R9 (finding #6, no locator exemption). **This is a real
+ narrowing from R9**: R9's mode 2 (the literal-pinned-`--session-dir` file)
+ was plan-safe across restarts, unconditionally, given its precondition;
+ that precondition — and the plan-safety it bought — is retracted along with
+ it.
+- **What is lost, stated plainly, not glossed over [R16.7]:** plan-safety
+ across a tunstrap restart for **ports and `fetch_files`** is gone entirely
+ — it existed only via R9's now-retracted pinned mode. A consumer needing a
+ saved plan to `apply` cleanly against fresh ports or fetched-file content
+ after a `run` restart has no supported mechanism under this design; the
+ only remaining recourse is re-running `plan` in the same tunstrap
+ invocation that produced the current `output.json`. **Kube stays plan-safe**
+ via the env-native channel (mode 1), which R16 does not touch — this loss
+ is specific to the data that has no provider-native env equivalent.
+- Finding #2 still applies unchanged to mode 2: **outputs freeze silently** —
+ `file()` read *through an output* (or through any value only computed once
+ at plan time and never re-touched) returns the plan-time content at apply,
+ with **no error**. Read `get_env("TUNSTRAP_OUTPUT_FILE")`/`file()` directly
+ inside the provider/resource config block that consumes it, never through
+ an intermediate `output` block.
+- **Q3's resource-attribute warning still applies unchanged**: binding
+ live data to a *resource* attribute (not a provider config block) produces
+ `Error: Provider produced inconsistent final plan`, confirmed for
+ `hashicorp/kubernetes` v2.38.0. The provider-config-block placement is the
+ only supported shape for the kube path and for any unified-structure value
+ read at apply time, in both modes 2 and 3.
+
+### Reconciliation with the ticket's "nothing live enters Terraform" framing [U6, restated iteration 6 — R12; ports bullet corrected iteration 7 — R16]
+
+**Stated honestly, not glossed over, and corrected from an earlier revision
+that scoped this reconciliation too narrowly.** Ticket #15's own framing says
+"connection data should stop travelling through Terraform input variables,"
+and the pre-pivot recipe's three delivery conditions included "no connection
+data enters an input variable." The reconciliation below is scoped by *kind
+of channel*, not just *kind of data* as an earlier revision put it — because
+kube itself now has **two** channels, and they do not have the same
+relationship to the ticket's framing:
+
+- **Kube env channel** (mode 1 above): the ticket's "nothing live enters
+ Terraform" holds in full, unconditionally — no variable, no file, ever.
+- **Kube references carried inside the unified structure** (mode 2's file or
+ mode 3's var — `path`/`context`/`endpoint` per kube target, non-credential
+ per U4, but still *connection data* in the literal sense): **the ticket's
+ framing is superseded here too, not just for ports.** When a consumer binds
+ `--output-var` and reads `nodes..kube..path` (or `context` or
+ `endpoint`) from it, that is connection data travelling through a Terraform
+ input variable — exactly what the ticket wanted to stop — even though none
+ of those three fields is a credential. A consumer who needs the ticket's
+ strict guarantee for kube must use the env channel exclusively (Mode A in
+ "Documentation" below) and never bind any of `--output-var`'s `kube.*`
+ fields to a resource; choosing to use `--output-var` for kube at all is
+ choosing to accept the superseded framing, the same choice a consumer
+ reading ports from it already makes.
+- **Ports**: no env-native path exists for a generic TCP endpoint the way
+ `KUBE_CONFIG_PATH` exists for the Kubernetes provider convention — nothing
+ about `host:port` is any provider's own configuration vocabulary.
+ **[R16, corrected iteration 7]** An earlier revision of this bullet
+ claimed a "genuine third option" — a literal, operator-pinned file path
+ (R9's mode 2) — as ports' plan-safety story, re-adopting #14 fix 1. That
+ re-adoption is **retracted**: the user's own instruction, and the ticket's
+ own explicit rejection of fix 1 ("session root can stay ephemeral; only the
+ path to the kubeconfig has to be stable"), settle this the other way. Ports
+ read the same env-carried `TUNSTRAP_OUTPUT_FILE` locator kube's
+ non-env-native connection data would use (mode 2, "Delivery" above) — but
+ **one-shot only**, since the session root stays ephemeral and the file it
+ names does not survive a tunstrap restart. There is no remaining mechanism
+ that buys ports plan-safety *across a restart* the way kube's env-native
+ channel does — see "Stability contract," "what is lost," above, and
+ "Relationship to #14" immediately below for the corrected fix-1
+ disposition.
+
+This explicitly **supersedes the ticket's stricter framing for the unified
+structure's var form — both ports and kube references carried in it — while
+leaving the kube env channel's full compliance untouched.** See ADR entry 11
+for this reasoning recorded as a decision with its own alternatives
+considered.
+
+### Relationship to #14 [R15, new; fix-1 disposition corrected iteration 7 — R16]
+
+Ticket #15 explicitly supersedes most of #14. An earlier revision of this
+section (R15) re-adopted two of #14's original fixes for non-kube delivery;
+**iteration 7 (R16) corrects that: fix 1 is no longer re-adopted, only fix
+4 is, and fix 4's own shape changes** (an env-carried locator, not a pinned
+path) — stated here so the correction reads as a decision, not scope creep
+from a superseded ticket, and cross-referenced from ADR entries 14 and 18 so
+the three documents cannot silently re-diverge on this point:
+
+- **#14 fix 1 (pin the session/state root) — [R16] NO LONGER re-adopted, in
+ either form.** An earlier revision (R15) re-adopted it as an opt-in
+ precondition (a caller-supplied, stable `--session-dir`) specifically to
+ give ports a plan-safe-across-restart story. The user's confirmed direction
+ after the red-team round retracts that: the session root stays ephemeral
+ unconditionally, matching the ticket's own explicit rejection of fix 1
+ ("session root can stay ephemeral; only the path to the kubeconfig has to
+ be stable, and that is supplied through the environment") — which R15's
+ re-adoption had, on reflection, only honored for kube while quietly
+ reintroducing the exact thing the ticket rejected for everything else. This
+ is not cost-free: see "Stability contract," "what is lost," above.
+- **#14 fix 4 (materialized file + `file()`) — re-adopted, reshaped.** The
+ mechanism (finding #1 measured it as plan-safe) survives, but **not** via a
+ pinned path anymore: the file is located by the env-carried
+ `TUNSTRAP_OUTPUT_FILE` (mode 2, "Delivery" above), one-shot within a single
+ tunstrap invocation, never a stable path the consumer's HCL independently
+ hardcodes. This is a narrower re-adoption than R15's — it buys plan-safety
+ within one invocation, not across a restart — but it is real, and it is
+ what "content on disk, paths in env" (R16's core principle) means
+ concretely for ports and `fetch_files`, which have no env-native provider
+ path the way kube does.
+- **#14 fix 3 (warn when the child's invocation captures a saved plan, e.g.
+ a `-out=` flag, while non-plan-safe delivery is in use)** — **explicitly
+ out of scope for #15, deferred to #14** (also listed in "Out of scope"
+ below so an implementer sees it as a deliberate deferral, not a gap). This
+ design's stability contract and the recipe's explicit warnings (below)
+ cover the risk in documentation; a runtime CLI warning would require
+ tunstrap to parse its own child command line for Terraform-specific flags
+ like `-out=`, which is exactly the kind of Terraform-vocabulary-inside-
+ generic-`run` trade the pre-#15 design deliberately confined to
+ `tunstrap_tofu` alone (`docs/specs/2026-07-31-run-env-io-and-tofu-proxy-
+ design.md`, "Shipping the shim") rather than adding to `run` itself. That
+ confinement is orthogonal to this pivot and not re-litigated here; fix 3
+ stays #14's remaining scope.
+
+### Consumer-side transformation [U5]
+
+The consumer parses the unified JSON with `jsondecode` and reshapes it in
+HCL `locals` into whatever their own tooling needs (a Terragrunt `inputs`
+map, a set of `provider` blocks, etc.) — the same pattern the pre-pivot
+recipe already used for the (smaller) `--output-var` payload.
+
+**Assumption recorded, not silently interpreted:** the user's instruction
+used the phrase "через js" ("via js"). This stack has no JavaScript runtime
+anywhere in its consumer chain (Terragrunt/OpenTofu, both Go binaries, HCL
+configuration language) — there is no `js`/`node` step between tunstrap's
+output and the consumer's config. This is read as **JSON** delivery consumed
+via HCL's `jsondecode` function, not literal JavaScript execution, and that
+interpretation is recorded here explicitly per the instruction to record
+assumptions rather than guess silently.
+
+### The scalar channel is removed, not extended
+
+Every remaining reference to `render_env`, `TUNSTRAP__*`,
+`inject_scalars`, or `MultiNodeEnvUnsupported` below describes what is
+**removed**, not a surviving single-node-only contract. **Three** scalars
+survive, deliberately, because they are session metadata rather than
+``-scoped connection data and because they solve a real bootstrapping
+need (locating the var/materialized-file payload from a plain shell context
+that hasn't parsed anything yet): `TUNSTRAP_SESSION_DIR`, `TUNSTRAP_PID`, and
+**`TUNSTRAP_OUTPUT_FILE`** (new — judgment call, not literally named by any
+of U1-U6, found while tracing the consequence of removing `render_env` from
+`start --output env`'s call site; see below). Every other
+`TUNSTRAP__*` key (`_HOST`, `_PORT`, `_ENDPOINT`, and the
+per-kube-target `_KUBECONFIG`/`_ENDPOINT` pair) is deleted outright, along
+with the `render_env` function that produced them, `predicted_env_keys`'
+per-target enumeration, and every raise site of `MultiNodeEnvUnsupported`
+(the class itself is removed — see below).
+
+**Why a third survivor.** `start --output env`'s exported lines shrink
+correspondingly to the same three-survivors-plus-kube-channel shape (it calls
+`render_env` too, at a second call site — `cli.py:206` — deleting the
+function without touching this call site breaks `start` outright, not just a
+test; see the plan). Doing that naively (dropping to only
+`TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`) leaves `start --output env` with **no
+way at all** to tell a plain shell consumer (not an HCL consumer — `start`
+has no Terraform-shaped output channel, unlike `run`) where a plain
+`remote_targets` port landed, which is a real functional regression, not a
+cosmetic one: `TUNSTRAP_WEB_PORT` used to be the only thing `--output env`
+existed to provide for that case. `TUNSTRAP_OUTPUT_FILE` — the absolute path
+to the materialized `/tunnel-data/output.json` (see "Delivery,"
+above; **[R13, corrected iteration 6]** only `start --output env` gains
+materialization, mirroring `run`'s new unconditional write — `start`'s other
+modes are untouched, see "Compatibility" below) — restores that: a shell
+consumer of `--output env` (or of `run`'s child
+environment, for a non-Terraform child) does
+`jq .nodes.web.ports.service1 "$TUNSTRAP_OUTPUT_FILE"` instead of reading a
+now-nonexistent scalar. This is safe to expose as a plain env var (unlike
+`session_dir` alone driving an HCL `file()` call) precisely *because* the
+shell/non-Terraform consumer this scalar serves can read arbitrary env vars —
+the "only `TF_VAR_*`-mapped names are visible to HCL" constraint that shaped
+the var-vs-materialization design above applies to Terraform config, not to
+this scalar's actual audience.
+
+`MultiNodeEnvUnsupported`'s disposition: **removed entirely, class and all**.
+Its only purpose was guarding the scalar channel's node-count collision; with
+the scalar channel gone, no code path can raise it. The pre-spawn gate at
+`cli.py:640` (`len(schema.nodes) != 1 and output_var is None` → exit 1) is
+removed with it: multi-node input no longer needs an explicit
+`--output-var` opt-in, because the unified structure is materialized
+unconditionally regardless of node count or of whether `--output-var` was
+passed — "unified output is emitted regardless of node count" is the pivot's
+own stated semantics for the `inject_scalars` gate, and once that gate no
+longer decides *whether* an alternate channel exists (materialization always
+does), the gate that used to force choosing one has nothing left to protect
+against.
+
+## Multi-node kube channel [kube part, unchanged by the pivot — U4]
+
+`render_env` currently has one node-count guard covering three unrelated
+things at once: the `TUNSTRAP__*` scalars, the per-kube-target
+`TUNSTRAP__{KUBECONFIG,ENDPOINT}` scalars, and the plain `KUBECONFIG`
+colon-joined list (`envrender.py:24-60`). Only the first two actually have a
+node-dimension problem — a target named `k3s` on two different nodes
+genuinely collides in `TUNSTRAP_K3S_PORT`. The `KUBECONFIG` line does not:
+it is *already* a path list, and `render_env` *already* colon-joins it
+(`envrender.py:56-59`) — the only thing stopping it from working across nodes
+is the early-return that raises before it is ever built. **This subsection
+describes the kube channel's own contract, which the pivot does not change;
+only its wiring into `_build_child_env` simplifies, per the rewritten "`cli.py`
+wiring" subsection below, once the scalar channel it used to branch around no
+longer exists.**
+
+**Split**, per the spike's Axis 2:
+
+- `render_kube_env(output: OutputSchema) -> dict[str, str]` — new. No
+ node-count guard. Iterates every node's `kube_targets`, in order, collecting
+ one materialized `path` per kube target across the **whole** envelope (not
+ one node), and builds the conditional env-export contract below from that
+ flat path list. Callable for any node count, including zero (returns `{}`)
+ and multi-node.
+- `render_env(output: OutputSchema) -> dict[str, str]` — unchanged contract
+ **at the point this split lands** (Task 3 in the plan). Still requires
+ `len(output.connections) == 1` and still raises `MultiNodeEnvUnsupported`
+ otherwise (`MultiNodeEnvUnsupported`'s own docstring, `exceptions.py:80-87`,
+ already states the reason precisely: *"has no node dimension"* — a claim
+this change does not touch). For the single-node case, `render_env` now
+delegates its kube-path-list line(s) to `render_kube_env`. **[Editorial fix,
+iteration 6]** This is **not** "reproducing the previous combined behaviour
+exactly," as an earlier revision put it — precisely: `KUBECONFIG`'s *value*
+(the colon-joined path list) is unchanged, but the *key set* grows, by
+design, per the conditional cardinality contract this same split introduces
+(e.g. a single materialized kube target now also exports `KUBE_CONFIG_PATH`
+alongside `KUBECONFIG`, which the pre-split `render_env` never did). The ADR
+carries the same correction (decision 2). **[PIVOT correction]** `render_env`
+ itself is **not** part of "kube part, unchanged by the pivot" — only
+ `render_kube_env`, the function this bullet's sibling describes, survives.
+ `render_env` and `MultiNodeEnvUnsupported` are both deleted once the
+ unified output contract lands (see "The scalar channel is removed" above);
+ this bullet describes their contract as it stands in the plan's Task 3,
+ before Task 5 removes them, not the shipped end state.
+
+`predicted_env_keys` (`envrender.py:83-112`) — the pre-spawn predictor used to
+reject a colliding `--output-var` NAME before a daemon exists — gains the same
+conditional logic in lockstep (see env-export contract below); the anti-drift
+guard test (`test_predicted_env_keys_matches_render_env`,
+`test_envrender.py:96-126`) is **extended, never weakened**, to assert the
+predictor and `render_env` agree exactly for both the one-file and
+two-or-more-file cases. **[PIVOT time-scope note, matching the correction
+above]** This paragraph describes the pairing as it stands at the plan's
+Task 3 (`predicted_env_keys` vs. `render_env`) — accurate at that point, not
+the shipped end state. Once Task 5 deletes `render_env`, the guard's *other
+half* changes, not its existence: the anti-drift property (two independent
+"what will `run` inject" implementations must agree) still matters and the
+guard is **re-scoped, not deleted** — the pair it compares becomes
+`predicted_env_keys(schema)` vs. the actual key set `_build_child_env`
+injects for a corresponding `OutputSchema`, since that is the pair capable of
+silently diverging once `render_env` is gone. See "The scalar channel is
+removed" above and the plan's Task 5 for the concrete rewritten test.
+
+### `cli.py` wiring is in scope [simplified under the pivot]
+
+**Iteration 2 shipped a two-branch `_build_child_env`** (`inject_scalars=True`
+→ `render_env`, which delegated to `render_kube_env`; `inject_scalars=False`
+→ `render_kube_env` directly), built to satisfy the ruling that "the kube
+channel's trigger condition is `kube_targets` presence, not node count and
+not `inject_scalars`'s value" while `render_env`'s scalar half still existed
+as something to branch around.
+
+**Under the pivot that branch collapses.** `render_env` (the scalar-emitting
+function) is deleted outright (see "The scalar channel is removed" above), so
+there is nothing left to delegate from and nothing left to branch on.
+`_build_child_env` calls `render_kube_env(output)` **unconditionally**, every
+time, regardless of node count and regardless of whether the unified output
+is also being materialized/injected in the same call — the iteration-2
+ruling's requirement ("kube channel fires on `kube_targets` presence, not
+node count") is now satisfied trivially, by construction, because there is no
+other function it could have been routed through instead. This is a genuine
+simplification the pivot buys, not a new requirement: one function, one call
+site, no condition on it beyond `render_kube_env`'s own internal
+"`kube_targets` empty → return `{}`" check.
+
+**[Editorial fix, iteration 6 — the wiring description below was imprecise;
+corrected to match what the plan actually implements.]** Two more pieces of
+wiring exist alongside the unconditional `render_kube_env` call above, and
+they are **not** the same call site, nor both unconditional:
+
+- `render_output_var(output) -> str` — the function's name and signature are
+ unchanged from the pre-pivot design (still `OutputSchema -> str`, still
+ the value injected under `--output-var`); only its *body* changes, to build
+ the unified structure via a new function, `render_unified_output(output)
+ -> dict[str, Any]`, and serialize that instead of the old
+ `RunKubeTarget`-based projection. `_build_child_env` calls
+ `render_output_var` **only when `output_var is not None`** — this is
+ unchanged from the pre-pivot contract (no `--output-var` flag, no var
+ injected) and is **conditional**, not unconditional.
+- **Materialization is a separate, unconditional call, in a different
+ function.** It does not live inside `_build_child_env` at all: `run_command`
+ (`cli.py`) calls `render_output_var(output)` a **second** time (or a shared
+ helper that also calls `render_unified_output`), unconditionally, in its
+ success path, and writes the result to
+ `/tunnel-data/output.json` — independent of whether
+ `--output-var` was passed and independent of node count. See "The unified
+ output contract," "Delivery," mode 2 above for the full write-mechanism
+ description (R13), and the plan's Task 5 for the concrete call sites.
+
+Without this wiring the multi-node kube channel and the unified output are
+dead code reachable only by unit tests calling the render functions
+directly.
+
+## Env-export contract (superset rejected; conditional adopted) [kube part, unchanged by the pivot — U4]
+
+`docs/specs/2026-08-10-issue15-provider-env-precedence.md` (live-probed,
+source-cited against `hashicorp/kubernetes` v2.38.0 and `hashicorp/helm`
+v2.17.0) settles the question the ticket's work item 3 left open:
+
+- **Plain `KUBECONFIG` is not read by either provider — evidence differs in
+ strength per provider, split here rather than conflated [editorial fix,
+ iteration 6]:**
+ - `hashicorp/kubernetes`: source evidence (`kubernetes/provider.go`'s
+ `initializeConfiguration()`, no `KUBECONFIG` read) **and** the stronger
+ live negative control — setting `KUBECONFIG` to a **valid** kubeconfig
+ file still fails (`dial tcp 127.0.0.1:80: connect: connection refused`,
+ the provider's zero-value default), proving the provider never reads it
+ regardless of the value's validity.
+ - `hashicorp/helm`: source evidence (`helm/structure_kubeconfig.go`'s
+ `newKubeConfig()`, same absence of a `KUBECONFIG` read) **plus** a live
+ negative control that used a deliberately **wrong** path
+ (`KUBECONFIG=/definitely/wrong`, failing with "no configuration has been
+ provided") — weaker than kubernetes' valid-file negative control on its
+ own (an invalid path failing does not by itself rule out a partial read),
+ so the "not read at all" conclusion for helm rests more heavily on the
+ source reading than on this transcript alone.
+- **Provider resolution order**: configured `config_path` (whose own default
+ reads env `KUBE_CONFIG_PATH`) → configured `config_paths` → env
+ `KUBE_CONFIG_PATHS` (colon-split via `filepath.SplitList`) — confirmed by
+ source reading for both providers.
+- **`KUBE_CONFIG_PATH` wins over `KUBE_CONFIG_PATHS` when both are set** —
+ confirmed live for **both** providers, each with its own valid-file
+ transcript (`kubernetes` data source read; `helm_release` apply): with both
+ set, only the file named by `KUBE_CONFIG_PATH` is reachable; a cluster
+ reachable only through the `KUBE_CONFIG_PATHS` list is invisible.
+
+That last fact is why the spike's Axis 3 prototype — "export the superset,
+`KUBECONFIG` + `KUBE_CONFIG_PATH` + `KUBE_CONFIG_PATHS`, always" — is
+**rejected**, not adopted. Exporting `KUBE_CONFIG_PATH` unconditionally
+alongside `KUBE_CONFIG_PATHS` would silently shadow every cluster but the
+first the instant a second kube target is materialized — exactly the failure
+mode this whole design exists to prevent, just moved one layer down.
+
+**Adopted contract, conditional on how many files were materialized** (one
+materialized file per kube target, so this is a cardinality condition on
+`render_kube_env`'s collected path list):
+
+| Materialized kube files | `KUBECONFIG` | `KUBE_CONFIG_PATH` | `KUBE_CONFIG_PATHS` |
+|---|---|---|---|
+| 0 | not exported | not exported | not exported |
+| exactly 1 | `` | `` | **not exported** |
+| ≥ 2 | `::...` (colon-joined) | **not exported** | `::...` (colon-joined) |
+
+- `KUBECONFIG` is exported whenever any files exist, always as the full
+ colon-joined list — it is the kubectl/Helm-CLI convention, unaffected by the
+ provider precedence problem (no provider here reads it), and a human running
+ `kubectl` by hand still benefits from the complete list.
+- `KUBE_CONFIG_PATH` is exported **only** for the single-file case, where its
+ precedence-winning behaviour is exactly the desired outcome (there is only
+ one file to reach, so "wins over `KUBE_CONFIG_PATHS`" is moot).
+- `KUBE_CONFIG_PATHS` is exported **only** for the two-or-more case, and
+ `KUBE_CONFIG_PATH` **must not** be exported alongside it — the whole point
+ of the condition.
+
+**`predicted_env_keys` must NOT model the exact cardinality condition — it
+must over-approximate it, conservatively [R11, corrected iteration 6].** An
+earlier revision of this design had `predicted_env_keys` compute the *input*
+schema's exact `kube_targets` count and apply the same one-vs-two-or-more
+conditional `_kube_channel_keys` logic the actual export uses. That is
+wrong: `predicted_env_keys` runs **pre-spawn**, against the *input* schema,
+before any node has connected — but the *actual* materialized count can be
+**smaller** than the input count, because an optional (`required: false`)
+node or kube target can fail without failing the run (`manager.py:99-107`
+already builds `connections` from successful nodes only). Concretely: two
+kube targets declared in the input (→ predicted the `≥2` branch,
+`KUBE_CONFIG_PATHS` only) but one optional node fails at connect time (→ only
+one file actually materializes, and the real export uses the `==1` branch,
+`KUBE_CONFIG_PATH`). If `predicted_env_keys` had predicted the `≥2` branch's
+key set, it would **not** have reserved `KUBE_CONFIG_PATH` — a
+`--output-var KUBE_CONFIG_PATH` would then pass the pre-spawn collision check
+and get **silently overwritten** by the real, one-file export at
+`_build_child_env` time. This is exactly the collision the pre-spawn check
+exists to prevent, defeated by predicting from the wrong (optimistic) side of
+a value that can only shrink, never grow, between input and output.
+
+**Fix: reserve conservatively.** Whenever **any** node in the input schema
+declares `kube_targets` (regardless of exact count, regardless of how many
+of those targets are `required`), `predicted_env_keys` reserves **all three**
+kube env names — `KUBECONFIG`, `KUBE_CONFIG_PATH`, `KUBE_CONFIG_PATHS` — not
+the exact cardinality-conditional subset. This is deliberately a superset of
+what will usually actually be injected; that asymmetry is the whole point —
+over-reserving can only reject *more* `--output-var` names than strictly
+necessary (a false-positive usage error, cheap and immediately visible),
+while under-reserving risks a silent post-spawn collision (the failure mode
+this check exists to prevent). Under the scalar-channel removal above,
+`predicted_env_keys` also loses its entire per-target/per-node scalar
+enumeration and its `len(schema.nodes) == 1` branch — it collapses to:
+
+```
+{"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE"}
+ | ({"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"}
+ if any(node.kube_targets for node in schema.nodes.values())
+ else set())
+```
+
+unconditionally on node count, matching `_build_child_env`'s own
+unconditional `render_kube_env` call above (which itself stays exact,
+cardinality-conditional, computed from the *actual* materialized output —
+only the *predictor* becomes conservative, not the actual export). Its
+remaining job — reject an `--output-var` NAME that collides with an injected
+key — is otherwise unchanged, and the guard verifying the relationship
+between the two is **preserved, not deleted, and now two-part** (an earlier
+revision's "extended, never weakened"/"re-scoped" framing evolves into this,
+below) — see "Anti-drift guard extension."
+
+### Interaction with the tofu proxy's `suppress_kubeconfig`
+
+**Not covered by any ruling; identified while writing this contract, flagged
+here rather than silently folded in.** `tunstrap_tofu` sets
+`suppress_kubeconfig=True` (`tofu_proxy.py:155`) specifically so that a broken
+`TF_VAR_tunstrap` → `config_path` wiring fails loudly instead of silently
+still reaching the cluster through an inherited/injected `KUBECONFIG`
+(`cli.py:388-392`). **[Editorial fix, iteration 6 — narrowed]** The provider
+findings above show plain `KUBECONFIG` was never read by either provider's
+**own Go configuration chain** — so the guard was inert **for that specific
+purpose** (stopping `KUBECONFIG` from silently reconfiguring the
+`kubernetes`/`helm` providers themselves), not "inert all along" in general,
+as an earlier revision overstated. The same suppression is, and always was,
+load-bearing for a different, real audience: `KUBECONFIG` is the
+kubectl/Helm-**CLI** convention, and `tofu`'s children include `local-exec`
+provisioners and `external` data sources, which can shell out to `kubectl`
+or the `helm` CLI directly — both of which *do* honour plain `KUBECONFIG`.
+Suppressing it was never protecting a nonexistent fallback in general; it was
+(and is) protecting exactly those two provider-native config chains, while
+already correctly protecting kubectl/Helm-CLI-invoking children the whole
+time.
+
+**[Issue #14 fix, iteration 9 — the paragraph below is falsified; kept for
+the historical record of what iteration 6 actually shipped, not restated as
+current.]** Iteration 6 went on to conclude: "Once this design ships
+`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` — the vars the providers' own Go
+chains actually do read — the guard becomes load-bearing for that
+provider-native audience too, for the first time, and `_build_child_env`'s
+`suppress_kubeconfig` handling must drop **all three** exported names, not
+just `KUBECONFIG`." That is backwards: this same subsection already
+established that providers never read plain `KUBECONFIG` — they read
+`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` directly, which is exactly Mode A's
+delivery channel through the proxy, not a fallback for it. Dropping those
+two through `suppress_kubeconfig` does not close a silent-fallback gap; it
+deletes Mode A's only channel through `tunstrap_tofu`, the documented
+`terraform_binary` entry point — measured against a real tunnel by the
+issue #14 report: through `tunstrap_tofu` all three names came back unset,
+and a provider block following Mode A's own item 1 (only `config_context`
+set) failed against the inert `localhost:80` loopback.
+
+**Corrected contract.** `suppress_kubeconfig` drops only the *injected*
+`KUBECONFIG` — never `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`, so Mode A keeps
+working through `tunstrap_tofu` exactly as documented. Two further
+guarantees hold unconditionally, on both the plain and the proxied path,
+independent of `suppress_kubeconfig`: an *inherited*
+`KUBECONFIG`/`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from the parent
+environment is always dropped before `render_kube_env` injects, so a stray
+operator environment can never contribute to the child's kube channel
+either. See `docs/specs/2026-08-07-issue15-kube-identity-decisions.md`
+entry 20 for the full record, including the alternative considered and
+rejected (pop-before-inject ordering) and why.
+
+## Compatibility
+
+Breaking, deliberately — org rule, no backward compatibility unless
+instructed:
+
+- Upstream context/cluster/user names in the materialized kubeconfig change
+ from whatever the source cluster used to `tunstrap--`.
+- `KubeTargetOutput.context_name` and `.cluster_name` report the **new**
+ names, not the upstream ones — both fields are plain `str` with no
+ validation tying them to the source document (`schemas.py:317-336`), so
+ this requires no schema change, only a different value at construction
+ time.
+- **[Editorial fix, iteration 6]** `RunKubeTarget` (`schemas.py:339-372`)
+ does **not** "carry the same fields through unchanged" into the unified
+ structure, as an earlier revision claimed — that class is **deleted**
+ under the pivot (its allow-list job is now done by explicit-keyword
+ construction inside `render_unified_output`, see the plan). Of its seven
+ fields, only `context_name` survives into the consumer channel, renamed to
+ `context`; `cluster_name`, `local_port`, `tls_server_name`, and
+ `certificate_authority_data` are **dropped** — a real, intentional breaking
+ narrowing beyond the pre-#15 credential fix, not an oversight (design
+ rationale: U4 scopes the unified kube entry to `{path, context, endpoint}`
+ references only). A consumer reading any of the four dropped fields out of
+ the old `--output-var` payload breaks.
+- **[Editorial fix, iteration 6]** The superset env export the ticket's work
+ item 3 asked to prototype as a placeholder — final choice deferred to the
+ parallel provider-behaviour verification (paraphrased, not a ticket
+ quotation; an earlier revision rendered this in quotation marks as if it
+ were verbatim) — is explicitly **not** the shipped contract; see
+ "Env-export contract" above.
+- **[PIVOT, iteration 3]** The entire `TUNSTRAP__*` scalar channel is
+ removed, not extended — every `run`/`start --output env` consumer reading
+ `TUNSTRAP__PORT` or similar breaks outright, with no compatibility
+ shim. **Three** survivors, not two (corrected, iteration 4):
+ `TUNSTRAP_SESSION_DIR`, `TUNSTRAP_PID`, `TUNSTRAP_OUTPUT_FILE` (session
+ metadata, not target-scoped).
+- **[R11, iteration 6]** `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` export
+ behaviour changes for multi-node: pre-pivot, the kube env channel was
+ gated by the same single-node guard as the scalars and so was never
+ injected for multi-node input at all; post-pivot it is injected
+ unconditionally on node count (`render_kube_env` has no node-count guard —
+ see "Multi-node kube channel" above), so a multi-node `run` now exports
+ `KUBECONFIG`/`KUBE_CONFIG_PATH(S)` where it previously exported nothing
+ kube-related. `predicted_env_keys` reserves all three names conservatively
+ whenever *any* node declares `kube_targets`, regardless of how many
+ actually materialize — see "Env-export contract," `predicted_env_keys`
+ paragraph.
+- **[PIVOT, iteration 3]** `--output-var NAME`'s injected JSON shape changes
+ from the raw `OutputSchema`-minus-kube-credentials projection to the
+ unified structure above — any consumer parsing the old flat
+ `connections..ports.` shape breaks; the new shape is
+ `nodes..ports.` (a string, not an int) plus the reorganized
+ `kube`/`fetch_files`/`session` keys.
+- **[PIVOT, iteration 3]** `MultiNodeEnvUnsupported` is removed entirely
+ (class, `_EXIT_CODES` entry, and both raise sites — `render_env`'s internal
+ guard and `cli.py:640`'s pre-spawn multi-node-without-`--output-var` gate).
+ A multi-node `run` without `--output-var` is no longer a usage error: the
+ unified structure is materialized unconditionally, so multi-node input
+ always has a channel.
+- **[PIVOT, iteration 3]** `run` now unconditionally writes
+ `/tunnel-data/output.json` — new on-disk artefact, new content
+ in a directory whose kube-only contents were previously the sole thing
+ present. **[R13, corrected iteration 6]** `start`'s **default** JSON stdout
+ gains no such artefact and is otherwise unaffected by this design — only
+ `start --output env` materializes, matching `run`, and only because it
+ shares `run`'s new `--output env` export shape (see "The scalar channel is
+ removed" above).
+- **[Editorial fix, iteration 6]** Scope carve-out, stated explicitly: the
+ unified contract covers `run`'s `--output-var`, `run`'s materialization,
+ and `start --output env`'s export lines. **`start`'s default/raw JSON
+ stdout envelope (no `--output` flag, or `--output json`) is unchanged by
+ this design** — a deliberate scope judgment call (plan, Self-Review), not
+ an oversight: it remains the pre-#15 "complete envelope" contract for
+ session-management tooling, a different audience than the consumer-facing
+ channels this pivot reshapes.
+- **[R16, iteration 7 — supersedes the iteration-6 bullet above, retracted,
+ not extended]** `fetch_files` content is **no longer** plan-file-durable
+ via the var form at all — the pre-#15 design's own choice to let
+ `content_b64` ride `--output-var` unprojected (decision history's
+ `fetch_files[*].content_b64` entry, cited by the retracted bullet this
+ replaces) is superseded. Fetched bytes are now materialized to
+ `tunnel-data/-` (mode `0600`) exactly like kubeconfigs
+ already were, and the consumer-facing projection — both the
+ `TUNSTRAP_OUTPUT_FILE` manifest and `--output-var` — carries only
+ `{path, size, sha256}` (or `{error}`), never `content_b64`. **This resolves
+ the "never fetch secrets with `--output-var`" warning as a class, not case
+ by case**: since content never rides the var (or the manifest) at all, a
+ consumer using `--fetch` to retrieve a secret can no longer leak it into a
+ saved Terraform plan file via that channel, regardless of whether
+ `--output-var` is bound. This is breaking versus the pre-#15 fetch-files
+ design, stated plainly: any consumer decoding `fetch_files..content_b64`
+ out of the old envelope breaks and must instead read the file at
+ `fetch_files..path`. `start`'s raw default JSON stdout keeps showing
+ `content_b64` unchanged (see "Fetched-file materialization" above,
+ mirroring the existing kube carve-out) — this narrowing is specific to the
+ consumer-facing channels R16 reshapes, not to `FetchedFile` itself.
+
+## Testing contract
+
+### The collision trap — mandatory, unit-level
+
+k3s ships `current-context: default`, `cluster: default`, `user: default` —
+**two k3s targets collide on the exact upstream names verbatim**; this is the
+expected case the rename exists to fix, not an edge case. **A kind-based test
+proves nothing here**: kind's context is `kind-`, already
+unique, so a kind-only regression test would pass unchanged even with the
+rename entirely absent. The mandatory regression test therefore uses two fake
+upstream kubeconfigs whose context/cluster/user names are **identical**
+(k3s-style), not kind-style — see the untracked spike prototype and
+`variant/combined`'s `tests/unit/test_issue15_context_collision.py`. It drives
+`run_kube_targets` twice (two different `node_name`s, same k3s-style fixture
+content) and asserts:
+
+- the two `KubeTargetOutput.context_name`/`.cluster_name` values differ, and
+ match `tunstrap--kube` exactly;
+- the rename reaches the **serialized** document (`content_b64`), not just the
+ extracted fields — a consumer parsing the materialized file, not
+ `KubeTargetOutput`, is what actually merges kubeconfigs.
+
+Confirmed RED against the unmodified `feature/run-env-io` tip (both outputs
+report `context_name == "default"`) and GREEN under `variant/combined`
+(spike findings, "Part 3").
+
+### Two more mandatory unit tests, distinct defect classes [R10, R14, new]
+
+**Neither is covered by the collision trap above** — restated because both
+are easy to mistake for "the same test, differently framed" and neither is:
+
+- **R10 — tunstrap's own naming scheme colliding with itself**, independent
+ of any upstream kubeconfig content: two different `(node, target)` pairs,
+ e.g. `(node="a-b", target="c")` and `(node="a", target="b-c")`, both
+ render `tunstrap-a-b-c`. Driven at schema-validation time (no SSH, no
+ kubeconfig fixture needed at all — this is a pure `InputSchema`
+ validation test), asserting the payload is rejected with an error naming
+ both colliding pairs.
+- **R14 — a dangling reference inside an *ignored* context after rename.**
+ A fixture with two contexts sharing one cluster entry: the current context
+ (renamed) and a non-current, ignored context whose own `context.cluster`
+ reference names the *same* cluster. Assert that after `rename_identities`
+ runs, the ignored context's `cluster`/`user` references have been updated
+ to the new name too — not left pointing at a cluster/user entry that no
+ longer exists under its old name anywhere in the document.
+
+### `e2e` coverage — optional, with rationale
+
+Kind-based `e2e` coverage of this feature is **not required** to land the
+fix, for the reason above: kind's own context naming already makes the
+collision unreachable, so an `e2e` test added naively would be decorative.
+**If** `e2e` coverage is added, it must first rewrite the two kind clusters'
+materialized kubeconfig identities to a **shared** name (e.g. force both to
+`current-context: default` / `cluster: default` / `user: default`, matching
+the k3s shape) before feeding them to `tunstrap start`/`run` — otherwise the
+test exercises kind's own uniqueness, not tunstrap's rename. This is real
+extra fixture work (a kubeconfig-identity rewrite step ahead of the existing
+`kube_rig`/`node_kubeconfig` fixtures in `tests/e2e/conftest.py`), which is
+why it is marked optional rather than mandatory in the plan — the unit-level
+regression test above already exercises the real defect precisely and does
+not need a live cluster to do so.
+
+**[Editorial fix, iteration 6 — disambiguated from a different e2e change
+this design also requires]** This optionality is about **collision-specific**
+e2e coverage only. It does not extend to the e2e tier's existing
+`nodes..kube..path` shape migration (`tests/e2e/module/main.tf`
+and its dependent test files), which **is** mandatory — the shape change
+ships with this design regardless, and a `try()` swallowing the shape
+mismatch into an empty `config_path` would fail silently rather than loudly
+if that migration were skipped, which is exactly the risk profile that makes
+it non-optional. See the plan's Task 6 for the mandatory migration and Task 7
+for where the two are kept distinct in the gate pass.
+
+### Anti-drift guard extension
+
+`test_predicted_env_keys_matches_render_env` (`test_envrender.py:96-126`)
+must be extended with cases for both the exactly-one-file and
+two-or-more-file conditions of the env-export contract above — the guard is
+**extended, never weakened**. **[Editorial fix, iteration 6]** The concern
+this guard addresses (two independent implementations of "what keys get
+injected" silently diverging) is this codebase's own anti-drift discipline,
+established by the spike/this design's own review process — not, as an
+earlier revision misattributed it, "the ticket's own stated concern" (the
+ticket never mentions this guard or this failure mode at all). Confirmed in
+the spike: reverting only the `predicted_env_keys` half of the Axis-3
+superset change (not the conditional version specified here, but the same
+class of change) produced exactly one failure, with a clear diff (`Extra
+items in the right set: 'KUBE_CONFIG_PATH', 'KUBE_CONFIG_PATHS'`) —
+confirming the guard fires correctly and is not a false pin.
+
+**[R11, corrected iteration 6 — the guard is two-part, not a single equality]**
+Two prior corrections to this guard are both superseded by R11's conservative
+predictor (above), which changes what "agree" even means between the two
+implementations:
+
+- **Iteration 4** deleted the guard outright on the false premise that only
+ one implementation of the injected-key set remained after `render_env`'s
+ removal — wrong; `_build_child_env` and `predicted_env_keys` are still two
+ independent implementations.
+- **Iteration 5** retargeted it to a single full-set-equality assertion,
+ `predicted_env_keys(schema) == set(actual injected keys)` — this was
+ correct *only* as long as `predicted_env_keys` computed the exact
+ cardinality-conditional key set. R11 makes `predicted_env_keys`
+ deliberately **conservative** (reserves all three kube names whenever any
+ `kube_targets` exist, regardless of exact count), so exact equality can no
+ longer hold in the general case — a schema with exactly one kube target
+ that materializes cleanly now predicts `{KUBECONFIG, KUBE_CONFIG_PATH,
+ KUBE_CONFIG_PATHS}` (conservative) while the actual export is
+ `{KUBECONFIG, KUBE_CONFIG_PATH}` (exact, per the `==1` branch) — genuinely
+ unequal, correctly so.
+
+**The guard is now two independent tests, not one:**
+
+1. **Formula test (exact equality, unchanged in spirit from iteration 5):**
+ for a fixed, representative schema, `predicted_env_keys(schema)` equals a
+ hand-computed expected set reflecting the conservative rule exactly (e.g.
+ any `kube_targets` present → all three kube names, plus the three
+ survivors) — this proves the *formula* is implemented correctly, and is a
+ normal unit test, not a drift guard between two independent
+ implementations.
+2. **Safety-envelope test (subset, new, the actual anti-drift guard):**
+ `set(actual injected keys from _build_child_env(out)) ⊆
+ predicted_env_keys(schema)`, driven by a **cardinality-shrink** case — an
+ input schema declaring two kube targets (one on an optional node that
+ fails), producing an `OutputSchema` with only one kube target
+ materialized. This is the property that actually matters for the
+ pre-spawn collision check: predicted must always cover whatever actually
+ gets injected, in every direction cardinality can move between input and
+ output, and only a shrink case can falsify a formula that got the
+ direction of the conservatism backwards.
+
+Both tests live together in `tests/unit/test_envrender.py`; see the plan's
+Task 3 (formula) and Task 5 (safety-envelope, since it needs
+`_build_child_env` from Task 5) for the concrete literals. This remains a
+**standing ruling (R1: the guard is extended, never weakened)** — R11
+changes *what* the guard asserts, not whether one exists.
+
+### Unified output contract tests [PIVOT, new territory]
+
+Nothing in the spike prototypes the unified structure, its materialization,
+or the removal of `render_env`/`predicted_env_keys`' scalar half — these are
+new tests, not spike cherry-picks. At minimum: `render_unified_output`
+produces the shape above for a multi-node, multi-kube-target `OutputSchema`
+(including **[R16]** the `fetch_files` **projection** — `{path, size,
+sha256}`, no `content_b64`, not a passthrough — and the
+two-reserved-top-level-keys namespacing); materialization writes
+`/tunnel-data/output.json`
+at mode `0600` and it is valid JSON matching the injected `--output-var`
+payload byte-for-byte; a multi-node `run` **without** `--output-var` now
+succeeds (was: exit 1 `MultiNodeEnvUnsupported`) and the materialized file
+still exists; `predicted_env_keys` no longer enumerates per-target scalar
+keys and the anti-drift guard is re-scoped to the surviving
+`{TUNSTRAP_SESSION_DIR, TUNSTRAP_PID, TUNSTRAP_OUTPUT_FILE} ∪ kube-channel`
+set, compared against `_build_child_env`'s actual output rather than against
+`render_env` (which no longer exists) — see "Anti-drift guard extension"
+above. **A full grep-driven enumeration of every pre-existing test, fixture,
+and shipped artifact this removal breaks — across unit, integration, e2e and
+the recipe doc — is the authoritative blast-radius table in the plan's
+Task 5**, not repeated here; this spec states the contract, the plan states
+every concrete consequence of shipping it.
+
+## Documentation (work item 4) [R12, rewritten as two explicit consumer modes; Mode B rewritten iteration 7 — R16]
+
+`docs/recipe_terragrunt.md` gains **two explicit, named consumer modes**, not
+a single blended recipe — an earlier revision's "kube-only" +
+"unified-output" split by *feature area* is replaced by a split by
+*compliance level*, because that is the axis a reader actually has to choose
+on (per U6's restated reconciliation, above): does this consumer need the
+ticket's strict "nothing live enters Terraform" guarantee, or is
+materialization-primary/var-convenience acceptable? Write both modes in one
+coherent document (a real consumer may use Mode A for kube and Mode B for
+ports in the same module), but never present Mode B as satisfying Mode A's
+guarantee.
+
+**Mode A — env-native kube (satisfies the ticket's strict contract):**
+
+1. `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from tunstrap's own process
+ environment (never a `var.`-bound value, never a file read in HCL at all)
+ **plus a literal `config_context = "tunstrap--"` per
+ provider alias** — the ticket's own central pattern (finding #3), shown
+ with a **two-alias worked example** (two `kubernetes`/`helm` provider
+ blocks, one per target, each with its own literal `config_context`,
+ sharing the same `KUBE_CONFIG_PATHS` list) citing findings #3 and #5 by
+ number. `config_path`/`config_paths` need not be set explicitly at all in
+ this mode — the env vars alone resolve them per the provider's own
+ `EnvDefaultFunc`.
+ ```hcl
+ provider "kubernetes" {
+ alias = "node1_k3s"
+ config_context = "tunstrap-node1-k3s" # literal -- see warning below
+ }
+ provider "kubernetes" {
+ alias = "node2_k3s"
+ config_context = "tunstrap-node2-k3s"
+ }
+ ```
+2. **Warning, explicit:** never derive `config_context`'s value from
+ `var.tunstrap` or any other live/decoded data — it must be a literal
+ string in the config, matching the deterministic naming scheme exactly
+ (`tunstrap--`, "The deterministic-naming contract," above).
+ Deriving it live would reintroduce a variable-bound value for data that
+ has an env-native, fully static alternative, defeating the point of Mode
+ A.
+
+**Mode B — unified-file convenience (ports + kube references; does *not*
+satisfy the ticket's strict contract, stated plainly, not glossed over):**
+
+3. **[R16, iteration 7 — this item's HCL is rewritten, not just re-worded]**
+ **The shape**, with a worked HCL example using the env-carried
+ `TUNSTRAP_OUTPUT_FILE` locator — **never** a literal, operator-pinned path
+ (R9's mode 2, now retracted) and never `var.tunstrap_session_dir` or any
+ variable-derived locator; an earlier revision of this recipe used the
+ unsound variable-derived form, a later one used the now-retracted pinned
+ form, both corrected here:
+ ```hcl
+ locals {
+ tunnel = try(
+ jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))),
+ { nodes = {} },
+ )
+ }
+
+ provider "kubernetes" {
+ config_path = local.tunnel.nodes.node1.kube.k3s.path
+ }
+ ```
+ `get_env(...)` is Terragrunt's own function for reading the parent
+ process's environment into HCL — this is exactly the bridge tunstrap's
+ `run` (or `start --output env`) sets up by exporting
+ `TUNSTRAP_OUTPUT_FILE` before spawning the child. No caller-supplied
+ `--session-dir`, no operator-agreed literal path: `run` mints an ephemeral
+ session dir the same way it always has, and every invocation's own child
+ sees that invocation's own fresh path via the env var, never a stale one.
+ Read directly inside the `locals` block that feeds the provider config —
+ never through an `output`, per the stability contract's finding-#2
+ warning.
+4. **Ports lose their integer form in this shape** (a `"host:port"` string,
+ not a bare port number) — show the HCL extraction idiom explicitly, one
+ canonical form, not left to the reader to invent:
+ ```hcl
+ locals {
+ service1_port = split(":", local.tunnel.nodes.node1.ports.service1)[1]
+ }
+ ```
+5. **[R16, iteration 7 — corrected: item 3 is no longer plan-safe across a
+ restart]** **The stability contract**, restated plainly and matching
+ "Stability contract" above word-for-word on the load-bearing claims: Mode
+ B via `TUNSTRAP_OUTPUT_FILE` (item 3) **and** Mode B via `--output-var`
+ (`var.tunstrap`/`TF_VAR_tunstrap`) are **both one-shot `plan && apply`
+ only** — no saved-plan reuse across a tunstrap restart, for either, since
+ the session dir stays ephemeral (an earlier revision of this item claimed
+ item 3 was unconditionally plan-safe given a `--session-dir` precondition
+ — that precondition is retracted along with R9's mode 2, see "Relationship
+ to #14," above). State this as plainly as: *"Neither Mode B form survives
+ a tunstrap restart. If you need a saved plan to `apply` cleanly against
+ fresh ports or fetched-file content, re-run `plan` in the same tunstrap
+ invocation instead — there is no supported way to pin either form's
+ locator to a stable path across invocations."*
+6. **The `jsondecode`-not-JavaScript note** (U5): the recipe consumes JSON
+ via HCL's `jsondecode`, there is no JS runtime in this stack, and the
+ recipe should say so in one sentence to preempt the same question this
+ design had to resolve as an explicit assumption.
+7. **[R16, iteration 7 — this warning is retracted, not carried forward]**
+ The "never `--fetch` secrets with `--output-var`" warning two earlier
+ revisions of this recipe stated (one dropped it, the next restored it) is
+ **resolved as a class**, not restated: `fetch_files` content no longer
+ rides any consumer-facing channel at all — `--output-var` carries only
+ `{path, size, sha256}` (see "Compatibility," above). The recipe should say
+ this plainly instead of warning against something that can no longer
+ happen: *"Fetched file content never enters a Terraform variable or plan
+ file — only its path, size, and checksum do. Read the file itself at
+ `fetch_files..path` if you need its contents."*
+
+**Measured-facts list, corrected to cite all six of the ticket's own
+findings [editorial fix, iteration 6 — an earlier revision's list, despite
+its own header claiming "the ticket's own six findings," actually cited
+only four]:**
+
+- **#1** — provider configuration **is** re-evaluated at apply: a `file()`
+ read inside a provider block picks up a post-plan change (Mode B's basis).
+- **#2** — outputs **freeze silently**: `file()` read through an output
+ returns the plan-time value at apply, with no error — the nastiest failure
+ mode, name it as such.
+- **#3** — **[was missing]** per-alias `config_context` works with an
+ env-supplied kubeconfig path: two aliases, literal `config_context` each,
+ sharing one `KUBECONFIG`/`KUBE_CONFIG_PATHS` list, each reach their own
+ cluster — Mode A's basis, cited with the two-alias worked example above.
+- **#4** — **[was missing]** plan-safe end to end, measured live in the
+ unpublished #15 spike (no automated test covers this saved-plan mutation
+ scenario): plan with
+ one set of ports, mutate only the kubeconfig, apply the *saved* plan → the
+ alias uses the mutated value, zero "Mismatch between input and plan
+ variable value" — this is the e2e-level confirmation that Mode A's
+ env-native path really is plan-safe across a saved-plan reuse, not just a
+ theoretical consequence of finding #1.
+- **#5** — `KUBE_CONFIG_PATHS` is colon-separated on Linux (comma silently
+ falls back to `localhost:80`).
+- **#6** — a live value bound to a `var.` **does** trip "Mismatch between
+ input and plan variable value" on a saved plan — the negative control
+ behind the one-shot-only rule for Mode B's variable form (R9's original
+ finding, restated for the file form too under R16 since the file itself no
+ longer survives a restart either).
+- A live value bound to a **resource attribute** (not a provider config
+ block) produces `Error: Provider produced inconsistent final plan` —
+ confirmed for `hashicorp/kubernetes` v2.38.0 in this design's own
+ provider-findings probe (Q3), reproducing the ticket's #14 claim on
+ current provider versions. The recipe must show the provider-block
+ placement as the only supported shape in both modes and name this failure
+ mode explicitly as what happens if a reader tries the resource-attribute
+ shape instead.
+
+## Out of scope
+
+- Pruning ignored (non-current) contexts/clusters/users from the materialized
+ document — accepted residual risk, see "Rename scope" above.
+- A configurable identity-naming prefix — explicitly rejected by the ticket's
+ own org-rule citation.
+- Any change to `KubeParseError`, `parse_kubeconfig`'s section-parser split,
+ or `patch_view`'s server/TLS rewriting — all untouched by this design and
+ still pinned by `tests/unit/test_kube_parse.py`,
+ `tests/unit/test_kube_parse_invariants.py`, and
+ `tests/unit/test_kube_patch.py` respectively.
+- **[PIVOT]** Literal JavaScript execution or a JS runtime in the consumer
+ chain — "через js" is interpreted as JSON delivery consumed via HCL's
+ `jsondecode`, per the recorded assumption above; introducing an actual JS
+ step (e.g. a `local-exec` calling `node`) is not part of this design and
+ was never asked for beyond that phrase.
+- **[PIVOT]** A configurable materialized-output filename/location — fixed at
+ `/tunnel-data/output.json`, matching the fixed-naming
+ philosophy the kube identity contract already established (decision 4);
+ not separately requested, so not built.
+- **[R15, new]** **#14 fix 3 — a CLI-level warning when the child's tofu
+ invocation captures a saved plan (e.g. `-out=`) while non-plan-safe
+ delivery is in use.** Deliberately deferred to #14, not forgotten: this
+ design's stability contract and the recipe's explicit warnings cover the
+ risk in documentation; a runtime warning would require `run` to parse its
+ own child's command line for Terraform-specific flags, which the pre-#15
+ design deliberately confined to `tunstrap_tofu` alone, not generic `run` —
+ see "Relationship to #14," above, for the full reasoning.
+- **[R10, new]** A different join separator (replacing the hyphen in
+ `tunstrap--`) to eliminate the naming-collision surface
+ structurally instead of detecting it — a larger, unrequested change; the
+ validation-time collision check (above) is the shipped fix.
+
+## Pointers
+
+- `docs/specs/2026-08-10-issue15-provider-env-precedence.md` — committed
+ provider-precedence evidence behind the env-export contract above.
+- The untracked issue #15 spike notes contained the six-variant comparison and
+ regression-test prototype; they are not public reference material.
+- `docs/specs/2026-08-07-issue15-kube-identity-decisions.md` — the
+ decision-history companion to this design, one entry per decision with
+ alternatives considered and consequences.
+- `docs/superpowers/plans/2026-08-07-issue15-kube-identity.md` — the
+ implementation plan, cherry-picking from `variant/combined`.
diff --git a/docs/specs/2026-08-10-issue15-provider-env-precedence.md b/docs/specs/2026-08-10-issue15-provider-env-precedence.md
new file mode 100644
index 0000000..00a5b91
--- /dev/null
+++ b/docs/specs/2026-08-10-issue15-provider-env-precedence.md
@@ -0,0 +1,46 @@
+# Provider environment precedence for kubeconfig paths (issue #15)
+
+- Date measured: 2026-08-07
+- Probe runtime: OpenTofu v1.12.5; kind v0.30.0; `hashicorp/kubernetes`
+ v2.38.0; `hashicorp/helm` v2.17.0.
+- Scope: provider environment resolution relevant to tunstrap's kube channel.
+
+## Measured result
+
+For both providers, plain `KUBECONFIG` alone did not configure the provider.
+`KUBE_CONFIG_PATH` configured a single kubeconfig, and `KUBE_CONFIG_PATHS`
+configured a list. When both provider-specific variables were present,
+`KUBE_CONFIG_PATH` won over `KUBE_CONFIG_PATHS`.
+
+The Kubernetes probe read `data.kubernetes_namespace.system`; with only
+`KUBECONFIG` it tried `127.0.0.1:80` and failed, while either provider-specific
+variable read `kube-system` successfully. The Helm probe similarly failed with
+only `KUBECONFIG`; it applied successfully with either provider-specific
+variable. With an invalid `KUBE_CONFIG_PATH` and a valid
+`KUBE_CONFIG_PATHS`, Helm failed trying the invalid single path.
+
+## Decision supported by this result
+
+`render_kube_env` exports `KUBECONFIG` plus exactly one provider-specific
+variable: `KUBE_CONFIG_PATH` for one materialized file, or
+`KUBE_CONFIG_PATHS` for two or more. It must not export both provider-specific
+variables for multiple files, because the single-path variable would hide the
+list.
+
+The path-list separator was measured as colon on Linux. This is an observation
+from the stated runtime, not a cross-platform claim.
+
+## Source-level corroboration
+
+The measured behavior is consistent with the Kubernetes provider's documented
+configuration precedence and Helm's nested Kubernetes configuration, but this
+spec does not independently verify a source-level resolution order. The tagged
+source trees are [Kubernetes v2.38.0](https://github.com/hashicorp/terraform-provider-kubernetes/tree/v2.38.0)
+and [Helm v2.17.0](https://github.com/hashicorp/terraform-provider-helm/tree/v2.17.0).
+
+## Related measurement: resource attributes (Q3)
+
+With `hashicorp/kubernetes` v2.38.0, changing a file-derived
+`kubernetes_config_map_v1.data["value"]` between planning and applying a saved
+plan produced `Provider produced inconsistent final plan`. This result applies
+to a resource attribute, not a provider configuration block.
diff --git a/docs/superpowers/plans/2026-05-30-kube-targets.md b/docs/superpowers/plans/2026-05-30-kube-targets.md
index 3de8c16..9eb3b57 100644
--- a/docs/superpowers/plans/2026-05-30-kube-targets.md
+++ b/docs/superpowers/plans/2026-05-30-kube-targets.md
@@ -1,5 +1,8 @@
# Kube-targets Implementation Plan
+> **Redaction/repoint note (2026-08-10):** Replaced ignored scratch-file paths
+> with an accurate description of their untracked location; no plan step changed.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a self-contained "kube mode" to `tunstrap` so it reads a remote kubeconfig, forwards the apiserver port, probes the serving-cert SAN for `tls-server-name`, patches `server:`, and returns ready-to-use kubeconfig fields — eliminating the HCL-side reconstruction.
@@ -43,7 +46,7 @@
### Task 0.1: Record baseline test counts
**Files:**
-- Create: `docs/artifacts/2026-05-30-kube-targets-baseline.md`
+- Record the baseline in the untracked artifacts directory.
- [ ] **Step 1: Run the unit suite and capture counts**
@@ -62,7 +65,7 @@ Expected: all clean on the untouched baseline.
- [ ] **Step 3: Write the baseline artifact**
-Create `docs/artifacts/2026-05-30-kube-targets-baseline.md` with the recorded unit count, gate statuses, and the date. (This file is gitignored by design — do not attempt to commit it.)
+Record the unit count, gate statuses, and date in the gitignored artifacts directory; do not attempt to commit that scratch file.
- [ ] **Step 4: No commit**
@@ -2678,7 +2681,7 @@ git commit -m "docs: README kube mode, materialization, host-key, migration"
### Task 9.1: Full verification sweep
**Files:**
-- Create/Update: `docs/artifacts/2026-05-30-kube-targets-baseline.md` (final counts)
+- Create/update the untracked baseline record with final counts.
- [ ] **Step 1: Run the entire test + gate matrix**
diff --git a/docs/superpowers/plans/2026-08-07-issue15-kube-identity.md b/docs/superpowers/plans/2026-08-07-issue15-kube-identity.md
new file mode 100644
index 0000000..a306b7b
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-07-issue15-kube-identity.md
@@ -0,0 +1,2656 @@
+# Kubeconfig-as-identity delivery (issue #15) Implementation Plan
+
+> **Redaction/repoint note (2026-08-10):** Repointed provider evidence to its
+> committed spec and replaced local/ignored references with accurate placeholders.
+
+> **Status: historical record, NOT the executable plan.** This file carries the
+> full 8-iteration decision history inline. Implementers execute
+> `docs/superpowers/plans/2026-08-08-issue15-kube-identity-clean.md`
+> (the consolidated final state); apply any future correction THERE, and treat
+> this file as frozen.
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Revised (iteration 3): the unified-output-contract pivot.** Tasks 1-3 below
+(kube identity rename, mandatory collision test, kube env conditional
+contract) are **unchanged by the pivot** — U4, kube part stands as written,
+cherry-pick from the spike exactly as before. **Task 4 is entirely new**
+(unified output shape + `render_unified_output`, pure-function work). **Task 5
+is entirely new and absorbs iteration 2's old Task 4** (multi-node kube
+wiring): the old two-branch `_build_child_env` is superseded by one
+unconditional `render_kube_env` call, per decision history entry 13, and
+Task 5 additionally wires materialization and deletes `render_env`,
+`MultiNodeEnvUnsupported`, and `inject_scalars` in the same commit, since all
+three are the same edit site. The recipe task (renumbered Task 6, was Task 5)
+gains new content; the gate-pass task is renumbered Task 7 (was Task 6) with
+no content change beyond fixed cross-references.
+
+**Revised (iteration 6): a three-model red-team review of the pivot found 12
+consolidated findings, all addressed in place — task numbering is unchanged
+from iteration 4/5, only task *content*.** Highlights: Task 1 gains a naming
+collision check (R10) and a dangling-reference fix (R14); Task 3's
+`predicted_env_keys` ships the **conservative** superset formula directly,
+not the exact-cardinality one an earlier revision had it compute (R11 — no
+"Task 5 fixes it again" deferral needed here, the correct formula lands the
+first time); Task 5's materialization writer is a true atomic replace, not
+`_write_file` reused as-is (R13), and gains a stdin-mode `--output env`
+guard; Task 6's recipe is two explicit consumer modes (env-native kube /
+unified-file convenience), not a single blended one, and drops the unsound
+`var.tunstrap_session_dir` locator pattern entirely (R9, R12) — decision
+history entries 14-18 carry the full reasoning for each.
+
+**Revised (iteration 7): the user's confirmed post-red-team direction plus
+one added constraint (R16), superseding parts of R9/R12/R13/R15 — task
+numbering unchanged, only task content.** Core principle: **content on disk,
+paths in env.** Delivery collapses from three modes to two — R9's mode 2
+(the literal, caller-pinned `--session-dir` file) is retracted; the
+`--output-var` bridge survives only for bare `tofu`. `TUNSTRAP_OUTPUT_FILE`
+generalizes into the primary, env-carried locator for `run` (not just `start
+--output env`), read via `get_env(...)`/`file()`, never a pinned path.
+`fetch_files` content_b64 is removed from every consumer-facing channel: the
+daemon materializes fetched bytes to `tunnel-data/-`
+(mirroring the kube precedent exactly) and projects `{path, size, sha256}`
+instead — Task 5 gains this mechanism plus a dedicated `content_b64`
+blast-radius enumeration. **User's constraint, encoded explicitly:** the
+session dir stays mandatory *lifecycle* infrastructure (`daemon.pid`,
+`session.lock`, `stop`/recovery) — only its role as a *consumer-facing*
+locator is retracted, not the directory itself. See decision history entry
+19 (new) and the correction annotations on entries 14 and 18.
+
+**Goal:** Rename the materialized kubeconfig's cluster/user/context to
+`tunstrap--` (deterministic, per-target); make the `KUBECONFIG`
+export multi-node-safe; export the OpenTofu-provider-facing env vars under the
+conditional cardinality contract (never the naive superset) — all three
+unchanged by the pivot. **New:** replace tunstrap's entire consumer-facing
+output with one unified, node-qualified JSON structure, delivered as both an
+`--output-var` value and a materialized session-dir file (materialization
+primary); remove the `TUNSTRAP__*` scalar channel,
+`MultiNodeEnvUnsupported`, and `inject_scalars` entirely; document the
+resulting recipe (kube-only guidance unchanged, unified-output guidance new).
+
+**Spec:** `docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md`.
+**Decision history:** `docs/specs/2026-08-07-issue15-kube-identity-decisions.md`
+(entries 10-13 are the pivot; entry 9 is marked superseded, not deleted).
+**Reference implementation:** `variant/combined` in the scratch worktree
+`` — reviewed,
+475/475 pre-existing unit tests pass plus one new regression test (476/476).
+**Cherry-pick the `kube.py` rename change from it as-is; the `envrender.py`
+change needs its body replaced** — the spike's Axis 3 prototype (superset
+export) is superseded by the conditional contract below (design doc §"Env-
+export contract", decision-history #3). **Scope note, iteration 3:** the
+spike predates the pivot and covers the kube part only (Tasks 1-3 below);
+nothing in it prototypes the unified output contract, materialization, or the
+scalar-channel removal (Tasks 5-6) — those are new code with no spike
+reference to cherry-pick from. Do not re-derive the six OpenTofu findings or
+the provider-precedence findings; the latter is now committed in the provider-precedence spec.
+
+**Target branch:** `feature/run-env-io` (PR #13). Every task below assumes a
+checkout of that branch (not the spike worktree) as the working tree; the
+spike worktree is a read-only reference, never committed from directly.
+
+**Tech stack:** Python 3.10+, Pydantic v2, Click, ruamel.yaml, pytest +
+pytest-asyncio. Use `.venv/bin/{pytest,ruff,black,mypy,pylint,vulture}`.
+Integration/e2e tiers need Docker (+ `kind`/`kubectl`/`tofu` for e2e) on
+`PATH`; see `tests/README.md` for env flags.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|------|----------------|
+| `tunstrap/kube.py` | New `rename_identities(doc, node, target) -> str`; one call site in `run_kube_targets` between `patch_view` and `dump_kubeconfig`. **[unchanged by the pivot]** |
+| `tunstrap/envrender.py` | New `render_kube_env(output) -> dict[str,str]` (conditional cardinality contract, **unchanged by the pivot**) + a shared `_kube_channel_keys(count)` helper reused by `predicted_env_keys`. **[PIVOT]** New `render_unified_output(output) -> dict[str, Any]` (the unified structure) and `UnifiedOutput`/`UnifiedNode`/`UnifiedKubeRef` models (or equivalent — see Task 5); `render_output_var`'s body rewritten to serialize the unified structure instead of the old `RunKubeTarget` projection; `render_env` and `predicted_env_keys`' scalar half **deleted**. |
+| `tunstrap/schemas.py` | **[PIVOT]** New `UnifiedOutput`/`UnifiedNode`/`UnifiedKubeRef` Pydantic models (or placed in `envrender.py` — Task 5 picks one and states why). |
+| `tunstrap/exceptions.py` | **[PIVOT]** `MultiNodeEnvUnsupported` and its `_EXIT_CODES` entry **deleted**. |
+| `tunstrap/cli.py` | `_build_child_env`: **[PIVOT, supersedes iteration 2's two-branch design]** unconditional `render_kube_env(output)` call (no `inject_scalars` branch — the branch and the flag are both deleted); extend `suppress_kubeconfig` to drop all three kube env names (unchanged by the pivot); **[PIVOT]** new unconditional materialization write for `run`; `cli.py:640`'s pre-spawn multi-node-without-`--output-var` gate **deleted**. |
+| `tests/unit/test_kube_rename.py` | New. Direct unit tests for `rename_identities` as a pure function. **[unchanged by the pivot]** |
+| `tests/unit/test_kube_identity_collision.py` | New. The mandatory k3s-style collision regression test (promoted from the spike's throwaway `test_issue15_context_collision.py`, renamed to match this repo's non-issue-numbered test naming convention). **[unchanged by the pivot]** |
+| `tests/unit/test_kube_run.py` | Extend: `context_name`/`cluster_name` now assert the renamed value, not just "non-empty output". **[unchanged by the pivot]** |
+| `tests/unit/test_envrender.py` | Extend: `render_kube_env` cardinality cases (0/1/≥2 files, unchanged by the pivot, Task 3); `predicted_env_keys` cardinality cases (rewritten scope, **Task 5**). **[PIVOT]** New: `render_unified_output` shape tests (Task 4); every `render_env`-dependent test deleted by name, incl. the Task-3-era `test_render_kube_env_works_for_multi_node_while_render_env_still_rejects`; anti-drift guard **retargeted, not deleted** (Task 5). |
+| `tests/unit/test_cli_run_output_var.py` | Extend/rewrite: multi-node `run` with kube_targets gets the kube channel and the unified output in the child env, **with or without** `--output-var`; `suppress_kubeconfig` drops all three kube names; **[PIVOT]** every `TUNSTRAP__*`-scalar or `MultiNodeEnvUnsupported`-exit-code assertion deleted or rewritten (Task 5). |
+| `tests/unit/test_cli_run_output_var_projection.py` | **[PIVOT]** Retargeted, not left alone (missed twice before iteration 4) — the credential-scrubbing pin for the kube reference; shape moves to `nodes.*.kube.*`, expected field set narrows to `UnifiedKubeRef`'s three fields, one test deleted outright (Task 5). |
+| `tests/unit/test_cli_run_materialize.py` | **[PIVOT]** New. Materialization writer tests (session-dir file, mode, content, `TUNSTRAP_OUTPUT_FILE`) (Task 5). |
+| `tests/unit/test_cli_run.py`, `test_cli_run_input_env_scrub.py`, `test_cli_runner.py`, `test_cli_run_postspawn.py` | **[PIVOT]** Each retargets one pre-existing `TUNSTRAP__*` or `MultiNodeEnvUnsupported` assertion — full list in Task 5's blast-radius table (Task 5). |
+| `tests/unit/test_exceptions.py`, `tests/unit/test_tofu_proxy.py` | **[PIVOT]** Three `MultiNodeEnvUnsupported` cases deleted; two docstrings updated to drop `inject_scalars` framing (Task 5). |
+| `tunstrap/session.py` | **[PIVOT, R13]** Referenced, not necessarily modified — `_write_file`'s mode-fixed-at-creation property (not "atomic": no rename step) is either factored into a shared atomic-replace (temp file + `os.replace`) helper both kube materialization and `output.json` use, or the atomic-replace primitive is replicated inline in `cli.py` if that refactor isn't clean (Task 5). |
+| `tunstrap/schemas.py` (naming collision) | **[PIVOT, R10]** New `InputSchema`-level `model_validator` rejecting a payload where two `(node, target)` pairs render the same `tunstrap--` string (Task 1). |
+| `tests/integration/test_run_env_io.py`, `test_cli_modes.py` | **[PIVOT]** Retargeted for the same shape/scalar removal, proven against the real console script (Task 5 Step 7). |
+| `tests/e2e/module/main.tf`, `rig.py`, `test_tofu_providers.py`, `test_terragrunt_apply.py` | **[PIVOT]** Retargeted to `nodes.*.kube.*.path` (Task 6 Step 0) — mandatory, not the optional collision-specific e2e coverage. |
+| `docs/recipe_terragrunt.md` | Recipe section per the design doc's "Documentation" item — kube-only content unchanged **except its pre-existing `connections.*` shape, fixed in Task 6 Step 0 before new content is added**; **[PIVOT]** new unified-output-consumption + stability-contract + jsondecode-note content (Task 6 Steps 1-2). |
+
+---
+
+### Task 1: `rename_identities` + call site in `kube.py` + naming collision check
+
+**Files:**
+- Modify: `tunstrap/kube.py` (rename_identities, R14 dangling-reference fix),
+ `tunstrap/schemas.py` (new naming-collision validator, R10)
+- Test: `tests/unit/test_kube_rename.py` (new), `tests/unit/test_kube_run.py`
+ (extend), `tests/unit/test_schemas_kube.py` or a new
+ `tests/unit/test_schemas_kube_naming_collision.py` (R10's collision test)
+
+- [ ] **Step 1: Write failing tests**
+
+`tests/unit/test_kube_rename.py`:
+
+```python
+"""rename_identities: deterministic tunstrap-- identity rename.
+
+Validates: the current-context's cluster/user/context are all renamed to the
+same tunstrap-- string; non-current entries are untouched;
+current-context itself is updated; the return value is that shared name.
+Code: tunstrap/kube.py::rename_identities
+Assertion: post-call doc state matches exactly, including the untouched
+ignored entries; the returned name equals every renamed field.
+Method: build a minimal ruamel-shaped dict (plain dicts are sufficient; the
+function only calls .get/[]/isinstance) with two contexts, call the function,
+inspect doc afterwards.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from tunstrap.kube import rename_identities
+
+pytestmark = pytest.mark.unit
+
+
+def _doc() -> dict[str, object]:
+ return {
+ "current-context": "default",
+ "contexts": [
+ {"name": "default", "context": {"cluster": "default", "user": "default"}},
+ {"name": "other", "context": {"cluster": "other-c", "user": "other-u"}},
+ ],
+ "clusters": [
+ {"name": "default", "cluster": {"server": "https://127.0.0.1:1"}},
+ {"name": "other-c", "cluster": {"server": "https://127.0.0.1:2"}},
+ ],
+ "users": [
+ {"name": "default", "user": {}},
+ {"name": "other-u", "user": {}},
+ ],
+ }
+
+
+def test_renames_current_context_cluster_and_user_to_shared_name() -> None:
+ """All three identity fields get the same tunstrap-- value."""
+ doc = _doc()
+ new_name = rename_identities(doc, "node-a", "kube")
+ assert new_name == "tunstrap-node-a-kube"
+ assert doc["current-context"] == "tunstrap-node-a-kube"
+ ctx = doc["contexts"][0]
+ assert ctx["name"] == "tunstrap-node-a-kube"
+ assert ctx["context"]["cluster"] == "tunstrap-node-a-kube"
+ assert ctx["context"]["user"] == "tunstrap-node-a-kube"
+ assert doc["clusters"][0]["name"] == "tunstrap-node-a-kube"
+ assert doc["users"][0]["name"] == "tunstrap-node-a-kube"
+
+
+def test_ignored_entries_are_left_untouched() -> None:
+ """Non-current context/cluster/user entries survive byte-stable."""
+ doc = _doc()
+ rename_identities(doc, "node-a", "kube")
+ assert doc["contexts"][1] == {
+ "name": "other",
+ "context": {"cluster": "other-c", "user": "other-u"},
+ }
+ assert doc["clusters"][1]["name"] == "other-c"
+ assert doc["users"][1]["name"] == "other-u"
+
+
+def test_two_nodes_same_upstream_names_get_distinct_results() -> None:
+ """The exact k3s-style collision case: same input, different node -> different name."""
+ assert rename_identities(_doc(), "a", "kube") != rename_identities(_doc(), "b", "kube")
+
+
+def test_ignored_context_sharing_the_active_cluster_keeps_a_valid_reference() -> None:
+ """[R14] A non-current context that references the SAME cluster/user the
+ active triple uses must have that reference updated too, or it dangles --
+ naming a cluster/user that no longer exists anywhere in the document
+ under its old name. The ignored context's own `name` is untouched (it is
+ not renamed itself, only its cluster/user references are); only entries
+ that neither ARE nor REFERENCE the active triple stay fully byte-stable."""
+ doc: dict[str, object] = {
+ "current-context": "default",
+ "contexts": [
+ {"name": "default", "context": {"cluster": "default", "user": "default"}},
+ # Shares the SAME cluster/user as the active context, under a
+ # different context name -- a legitimate, ordinary kubeconfig shape.
+ {"name": "staging", "context": {"cluster": "default", "user": "default"}},
+ ],
+ "clusters": [{"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}],
+ "users": [{"name": "default", "user": {}}],
+ }
+ new_name = rename_identities(doc, "node-a", "kube")
+ staging_ctx = doc["contexts"][1]
+ assert staging_ctx["name"] == "staging" # the ignored context's own name is untouched
+ assert staging_ctx["context"]["cluster"] == new_name # its reference is NOT left dangling
+ assert staging_ctx["context"]["user"] == new_name
+ # And the referenced entries genuinely exist under the new name.
+ assert doc["clusters"][0]["name"] == new_name
+ assert doc["users"][0]["name"] == new_name
+```
+
+Add to `tests/unit/test_kube_run.py` (extends the existing
+`test_run_kube_target_success`):
+
+```python
+@pytest.mark.asyncio
+async def test_run_kube_target_reports_renamed_identity(monkeypatch: pytest.MonkeyPatch) -> None:
+ """KubeTargetOutput.context_name/cluster_name are tunstrap--, not upstream."""
+ monkeypatch.setattr(
+ "tunstrap.kube.sans_from_cert",
+ lambda _der: (["dev-kube-1", "192.0.2.11"], []),
+ )
+ conn = _FakeConn((FIXTURES / "single_internal_ip.yaml").read_bytes())
+ outputs, _, _ = await run_kube_targets(
+ conn,
+ {"k3s": KubeTarget.model_validate({"kubeconfig_path": "/etc/k3s.yaml"})},
+ connect_timeout=5,
+ probe=_probe_ok,
+ node_name="edge",
+ )
+ out = outputs["k3s"]
+ assert out.context_name == "tunstrap-edge-k3s"
+ assert out.cluster_name == "tunstrap-edge-k3s"
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+`.venv/bin/pytest tests/unit/test_kube_rename.py tests/unit/test_kube_run.py -v`
+Expected: `test_kube_rename.py` fails on import (`rename_identities` missing);
+the new `test_kube_run.py` case fails because `context_name`/`cluster_name`
+still equal the fixture's upstream `"production"`.
+
+- [ ] **Step 3: Implement `rename_identities` in `kube.py`**
+
+Add after `patch_view`, before `dump_kubeconfig` (cherry-pick from
+`variant/combined`, `tunstrap/kube.py`, with the docstring's "V1c" spike
+framing dropped — that context belongs in the decision history, not the
+shipped code — and `__all__` gains `"rename_identities"`):
+
+```python
+def rename_identities(doc: dict[str, object], node: str, target: str) -> str:
+ """Rename the current-context's cluster/user/context to a deterministic name.
+
+ ``tunstrap--`` for cluster, user and context alike — see
+ docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md. Operates on
+ the raw parsed document alone; the current-context's own name is enough to
+ find every entry that needs renaming.
+
+ [R14] Every *other* context's cluster/user REFERENCES are also updated if
+ they name the same cluster/user being renamed here -- a kubeconfig can
+ legitimately have two contexts sharing one cluster or user entry, and
+ leaving such a reference unrenamed while the entry it points at IS renamed
+ would dangle it. Only entries that neither are, nor reference, the active
+ triple are left untouched; other contexts' own `name` fields are never
+ renamed, only their `cluster`/`user` reference fields when they match.
+
+ Returns the new name (shared by cluster, user and context alike).
+ """
+ new_name = f"tunstrap-{node}-{target}"
+ current = doc.get("current-context")
+ assert isinstance(current, str)
+ contexts_raw = doc.get("contexts")
+ contexts: list[object] = contexts_raw if isinstance(contexts_raw, list) else []
+ ctx_entry = _find_named(contexts, current)
+ assert ctx_entry is not None
+ ctx_body = ctx_entry["context"]
+ assert isinstance(ctx_body, dict)
+ old_cluster = ctx_body["cluster"]
+ old_user = ctx_body["user"]
+ assert isinstance(old_cluster, str)
+ assert isinstance(old_user, str)
+
+ ctx_entry["name"] = new_name
+ ctx_body["cluster"] = new_name
+ ctx_body["user"] = new_name
+
+ cluster_entry = _find_named(doc.get("clusters") or [], old_cluster)
+ assert cluster_entry is not None
+ cluster_entry["name"] = new_name
+
+ user_entry = _find_named(doc.get("users") or [], old_user)
+ assert user_entry is not None
+ user_entry["name"] = new_name
+
+ doc["current-context"] = new_name
+
+ # [R14] Sweep every OTHER context for a reference to the cluster/user
+ # entries just renamed. This context's own `name` is not touched -- it is
+ # not becoming the current context, only its dangling reference is fixed.
+ for entry in contexts:
+ if entry is ctx_entry or not isinstance(entry, dict):
+ continue
+ other_body = entry.get("context")
+ if not isinstance(other_body, dict):
+ continue
+ if other_body.get("cluster") == old_cluster:
+ other_body["cluster"] = new_name
+ if other_body.get("user") == old_user:
+ other_body["user"] = new_name
+
+ return new_name
+```
+
+Wire the call site in `run_kube_targets` (replace the `patch_view` →
+`dump_kubeconfig` → `KubeTargetOutput` block):
+
+```python
+ patch_view(view, local_port=local_port, tls_server_name=tls_name, insecure=insecure)
+ assert isinstance(view.doc, dict) # parse_kubeconfig guaranteed this
+ new_identity = rename_identities(view.doc, node_name, name)
+ patched = dump_kubeconfig(view)
+ outputs[name] = KubeTargetOutput(
+ cluster_name=new_identity,
+ context_name=new_identity,
+```
+
+**Do not carry over the spike's `# type: ignore[arg-type]`** on this call —
+`view.doc` is typed `object` on `KubeconfigView` (dataclass field, `kube.py:56`)
+and `rename_identities` wants `dict[str, object]`; use the explicit
+`assert isinstance(view.doc, dict)` above instead, matching the pattern
+`patch_view` already uses two lines earlier (`kube.py:257`) — this keeps
+`mypy --strict` clean without a suppression.
+
+- [ ] **Step 4: Run to verify pass**
+
+`.venv/bin/pytest tests/unit/test_kube_rename.py tests/unit/test_kube_run.py tests/unit/test_kube_patch.py tests/unit/test_kube_parse.py tests/unit/test_kube_parse_invariants.py -v`
+Expected: all pass. (The last three files are the ones the spike confirmed
+are unaffected — this run is the regression check that confirms it stayed
+true after cherry-picking, not a re-derivation.)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tunstrap/kube.py tests/unit/test_kube_rename.py tests/unit/test_kube_run.py
+git commit -m "feat(kube): rename current-context identity to tunstrap-- (#15)"
+```
+
+- [ ] **Step 6: [R10] Write the failing naming-collision test**
+
+New file `tests/unit/test_schemas_kube_naming_collision.py`:
+
+```python
+"""[R10] tunstrap-- is NOT unique by construction.
+
+_FETCH_FILES_KEY_RE (schemas.py:11) permits internal hyphens in node/target
+identifiers, and the join itself uses a hyphen, so two DIFFERENT (node,
+target) pairs can render the SAME string: (node="a-b", target="c") and
+(node="a", target="b-c") both produce "tunstrap-a-b-c". This is a distinct
+defect class from the mandatory k3s-style collision test (Task 2) -- that
+test proves upstream kubeconfig names colliding is fixed by the rename; this
+test proves tunstrap's OWN naming scheme does not collide with itself,
+independent of any kubeconfig content at all.
+
+Code: tunstrap/schemas.py (new validator, InputSchema level)
+Method: construct an InputSchema with exactly the a-b/c vs a/b-c pair and
+assert validation rejects it, naming both colliding pairs.
+"""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from tunstrap.schemas import InputSchema
+
+pytestmark = pytest.mark.unit
+
+
+def test_naming_join_collision_across_nodes_is_rejected() -> None:
+ """(node='a-b', target='c') and (node='a', target='b-c') both join to
+ tunstrap-a-b-c -- reject the whole payload, naming both colliding pairs."""
+ with pytest.raises(ValidationError) as excinfo:
+ InputSchema.model_validate(
+ {
+ "nodes": {
+ "a-b": {
+ "host": "h1", "user": "u", "ssh_password": "p",
+ "kube_targets": {"c": {"kubeconfig_path": "/etc/x.yaml"}},
+ },
+ "a": {
+ "host": "h2", "user": "u", "ssh_password": "p",
+ "kube_targets": {"b-c": {"kubeconfig_path": "/etc/y.yaml"}},
+ },
+ }
+ }
+ )
+ message = str(excinfo.value)
+ assert "tunstrap-a-b-c" in message
+ assert "a-b" in message and "c" in message # first colliding pair
+ assert "a" in message and "b-c" in message # second colliding pair
+
+
+def test_non_colliding_hyphenated_names_are_accepted() -> None:
+ """Anti-vacuity: hyphens alone don't trigger the check -- only an actual join collision does."""
+ InputSchema.model_validate(
+ {
+ "nodes": {
+ "node-one": {
+ "host": "h1", "user": "u", "ssh_password": "p",
+ "kube_targets": {"kube-a": {"kubeconfig_path": "/etc/x.yaml"}},
+ },
+ "node-two": {
+ "host": "h2", "user": "u", "ssh_password": "p",
+ "kube_targets": {"kube-b": {"kubeconfig_path": "/etc/y.yaml"}},
+ },
+ }
+ }
+ )
+```
+
+- [ ] **Step 7: Run to verify failure**
+
+`.venv/bin/pytest tests/unit/test_schemas_kube_naming_collision.py -v`
+Expected: FAIL — no such validator exists yet; both tests currently pass
+`InputSchema.model_validate` without complaint (the first test's payload is
+wrongly accepted today).
+
+- [ ] **Step 8: Implement the collision check in `schemas.py`**
+
+Add an `InputSchema`-level `model_validator(mode="after")` (alongside the
+existing `_validate_auth` field validator, `schemas.py:278-289`) — this must
+run at the `InputSchema` level, not per-`NodeInput`, since the collision is
+cross-node:
+
+```python
+@model_validator(mode="after")
+def _validate_kube_identity_names_are_unique(self) -> InputSchema:
+ """[R10] tunstrap-- is not unique by construction (hyphens
+ are legal in both node and target names); reject a payload where two
+ different (node, target) pairs join to the same rendered identity."""
+ seen: dict[str, tuple[str, str]] = {}
+ for node_name, node in self.nodes.items():
+ for target_name in node.kube_targets or {}:
+ joined = f"tunstrap-{node_name}-{target_name}"
+ if joined in seen:
+ other_node, other_target = seen[joined]
+ raise ValueError(
+ f"kube identity name collision: ({node_name!r}, {target_name!r}) "
+ f"and ({other_node!r}, {other_target!r}) both render {joined!r}"
+ )
+ seen[joined] = (node_name, target_name)
+ return self
+```
+
+Place this near `InputSchema`'s existing `_validate_auth` validator so both
+cross-node checks live together. Note this validator is intentionally at
+`InputSchema` level (has access to every node), not on `NodeInput`
+(single-node scope, cannot see the collision) — do not move it there even
+though `kube_targets` is a `NodeInput` field.
+
+- [ ] **Step 9: Run to verify pass, then commit**
+
+`.venv/bin/pytest tests/unit/test_schemas_kube_naming_collision.py tests/unit/test_schemas.py tests/unit/test_schemas_kube.py -v`
+Expected: all pass.
+
+```bash
+git add tunstrap/schemas.py tests/unit/test_schemas_kube_naming_collision.py
+git commit -m "feat(schemas): reject tunstrap-- naming collisions (#15, R10)"
+```
+
+---
+
+### Task 2: The mandatory collision regression test
+
+**Files:**
+- Create: `tests/unit/test_kube_identity_collision.py`
+
+This is the trap the design doc's testing contract calls out by name: it must
+land, unmodified in substance, regardless of how Task 1 was implemented.
+
+- [ ] **Step 1: Copy the spike's prototype under a repo-convention file name**
+
+Copy `tests/unit/test_issue15_context_collision.py` from the spike worktree
+(` `, branch
+`variant/combined`) to `tests/unit/test_kube_identity_collision.py` in this
+checkout — **only the file is renamed** (this repo's other test files never
+carry an issue number, e.g. `test_kube_run.py`, `test_envrender.py`). **Keep
+the test function name unchanged**,
+`test_two_k3s_style_targets_get_distinct_deterministic_identities` — it is
+already descriptive and needs no rename. Drop the module docstring's "spike"
+framing, replacing it with a plain description (content is otherwise correct
+verbatim; see the untracked spike's Part 3 for the exact source).
+
+- [ ] **Step 2: Run to verify it is GREEN after Task 1**
+
+`.venv/bin/pytest tests/unit/test_kube_identity_collision.py -v`
+Expected: PASS. (It was confirmed RED against the unmodified branch and GREEN
+under `variant/combined` during the spike; this run confirms the same is true
+against this checkout's own Task 1 implementation, not a re-derivation of the
+spike's own finding.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/unit/test_kube_identity_collision.py
+git commit -m "test(kube): regression test for the k3s upstream-name collision trap (#15)"
+```
+
+---
+
+### Task 3: `render_kube_env` + the conditional env-export contract
+
+**Files:**
+- Modify: `tunstrap/envrender.py`
+- Test: `tests/unit/test_envrender.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Add to `tests/unit/test_envrender.py` (uses the existing `_kube_out` helper):
+
+```python
+def test_render_kube_env_zero_files_returns_empty() -> None:
+ """No kube_targets anywhere -> no keys at all."""
+ out = OutputSchema(
+ connections={"h": NodeOutput(ports={"db": 1})},
+ pid=1, session_dir="/s", started_at="now",
+ )
+ assert render_kube_env(out) == {}
+
+
+def test_render_kube_env_one_file_sets_path_not_paths() -> None:
+ """Exactly one materialized file: KUBECONFIG + KUBE_CONFIG_PATH, no _PATHS."""
+ out = OutputSchema(
+ connections={"h": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/k3s")})},
+ pid=1, session_dir="/s", started_at="now",
+ )
+ env = render_kube_env(out)
+ assert env == {"KUBECONFIG": "/s/k3s", "KUBE_CONFIG_PATH": "/s/k3s"}
+ assert "KUBE_CONFIG_PATHS" not in env
+
+
+def test_render_kube_env_two_files_sets_paths_not_path() -> None:
+ """Two materialized files (could be one node, two targets, or two nodes):
+ KUBECONFIG + KUBE_CONFIG_PATHS, no _PATH -- KUBE_CONFIG_PATH would win over
+ KUBE_CONFIG_PATHS per the measured provider precedence and hide the second
+ cluster."""
+ out = OutputSchema(
+ connections={
+ "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/a-k3s")}),
+ "b": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7001, "/s/b-k3s")}),
+ },
+ pid=1, session_dir="/s", started_at="now",
+ )
+ env = render_kube_env(out)
+ assert env == {"KUBECONFIG": "/s/a-k3s:/s/b-k3s", "KUBE_CONFIG_PATHS": "/s/a-k3s:/s/b-k3s"}
+ assert "KUBE_CONFIG_PATH" not in env
+
+
+def test_render_kube_env_works_for_multi_node_while_render_env_still_rejects() -> None:
+ """The exact split render_env's own docstring claims: kube channel is
+ node-count-agnostic, scalar channel is not."""
+ out = OutputSchema(
+ connections={
+ "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/a-k3s")}),
+ "b": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7001, "/s/b-k3s")}),
+ },
+ pid=1, session_dir="/s", started_at="now",
+ )
+ assert render_kube_env(out) # does not raise
+ with pytest.raises(MultiNodeEnvUnsupported):
+ render_env(out)
+
+
+def test_predicted_env_keys_reserves_all_three_for_one_kube_target() -> None:
+ """[R11] predicted_env_keys is a CONSERVATIVE predictor, not exact: it
+ reserves all three kube names whenever ANY kube_targets are declared,
+ regardless of exact count -- input cardinality can shrink by output time
+ (an optional node/target can fail), so predicting the exact one-file
+ branch here would under-reserve KUBE_CONFIG_PATHS for a schema that
+ later, at runtime, actually produces >=2 files. render_kube_env's own
+ export (tested above) stays exact -- only the predictor is conservative."""
+ schema = InputSchema.model_validate(
+ {
+ "nodes": {
+ "node": {
+ "host": "h.example.net", "user": "u", "ssh_password": "p",
+ "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}},
+ }
+ }
+ }
+ )
+ keys = predicted_env_keys(schema)
+ assert {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} <= keys
+
+
+def test_predicted_env_keys_reserves_all_three_for_two_kube_targets_one_node() -> None:
+ """[R11] Same conservative reservation for the >=2 case -- the point is
+ that BOTH cardinalities reserve identically (all three), which is what
+ makes the predictor a safe over-approximation rather than a second exact
+ implementation of _kube_channel_keys."""
+ schema = InputSchema.model_validate(
+ {
+ "nodes": {
+ "node": {
+ "host": "h.example.net", "user": "u", "ssh_password": "p",
+ "kube_targets": {
+ "a": {"kubeconfig_path": "/etc/a.yaml"},
+ "b": {"kubeconfig_path": "/etc/b.yaml"},
+ },
+ }
+ }
+ }
+ )
+ keys = predicted_env_keys(schema)
+ assert {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} <= keys
+```
+
+**[R11] The anti-drift guard is two-part, not a single equality against
+`render_kube_env`/`render_env`** — a red-team finding, logic-verified, shows
+the naive "predict the exact cardinality branch" design under-reserves:
+`predicted_env_keys` runs pre-spawn against *input* cardinality, but an
+optional (`required: false`) node or kube target can fail without failing
+the run, so *output* cardinality can be smaller than what was declared. Two
+kube targets declared (predicting the `≥2` branch, `KUBE_CONFIG_PATHS` only)
+but one optional node fails at connect time → only one file actually
+materializes → the real export uses the `==1` branch (`KUBE_CONFIG_PATH`) —
+which the exact predictor never reserved. A `--output-var KUBE_CONFIG_PATH`
+would then pass the pre-spawn collision check and be **silently
+overwritten** by the real export. Fix: `predicted_env_keys` reserves **all
+three** kube names whenever *any* `kube_targets` are declared, not the exact
+per-count subset (implemented below); this is deliberately a superset of
+what usually gets injected, and over-reserving is the safe direction (a
+false-positive usage error, cheap and visible) versus under-reserving
+(a silent post-spawn collision). The anti-drift concern this guard encodes
+is this codebase's own discipline (spike/design review process), not a
+concern stated by the ticket itself — an earlier revision of this note
+misattributed it to "the ticket's own review process," corrected here. Add
+both new tests above (formula-correctness style, not drift-guard style —
+see decision history entry 16 for the full two-part-guard design); the
+drift-guard half (`actual ⊆ predicted`, driven by a cardinality-shrink case)
+is added in Task 5 once `_build_child_env` exists to compute "actual" from.
+
+- [ ] **Step 2: Run to verify failure**
+
+`.venv/bin/pytest tests/unit/test_envrender.py -v`
+Expected: FAIL — `render_kube_env` missing; `predicted_env_keys` still uses
+the unconditional `KUBECONFIG`-only rule.
+
+- [ ] **Step 3: Implement in `envrender.py`**
+
+Add a shared cardinality helper (used by both `render_kube_env` and
+`predicted_env_keys`, so the two cannot independently drift on this rule —
+this is stronger than the spike's structure, which computed the export dict
+inline in `render_kube_env` with no shared helper):
+
+```python
+def _kube_channel_keys(count: int) -> set[str]:
+ """Names of the kube-channel env keys the conditional contract exports.
+
+ 0 files: nothing. Exactly 1: KUBECONFIG + KUBE_CONFIG_PATH. >=2:
+ KUBECONFIG + KUBE_CONFIG_PATHS. KUBE_CONFIG_PATH and KUBE_CONFIG_PATHS are
+ never both present -- KUBE_CONFIG_PATH wins over KUBE_CONFIG_PATHS per the
+ measured OpenTofu kubernetes/helm provider precedence (docs/specs/
+ 2026-08-10-issue15-provider-env-precedence.md), so exporting both once a
+ second file exists would silently hide every cluster but the first.
+ """
+ if count == 0:
+ return set()
+ if count == 1:
+ return {"KUBECONFIG", "KUBE_CONFIG_PATH"}
+ return {"KUBECONFIG", "KUBE_CONFIG_PATHS"}
+
+
+def render_kube_env(output: OutputSchema) -> dict[str, str]:
+ """Build the node-count-agnostic kube channel: KUBECONFIG plus the
+ OpenTofu-provider-facing var the conditional contract picks.
+
+ Unlike the ``TUNSTRAP__*`` scalars, this channel has no node
+ dimension: it collects one materialized path per kube_target across every
+ node (not just a single one), so it is safe to call for any node count.
+ """
+ kube_paths: list[str] = []
+ for node in output.connections.values():
+ for kname, target in node.kube_targets.items():
+ if target.path is None:
+ raise ValueError(f"kube target {kname!r} not materialized; cannot set KUBECONFIG")
+ kube_paths.append(target.path)
+ if not kube_paths:
+ return {}
+ joined = ":".join(kube_paths)
+ return {key: joined for key in _kube_channel_keys(len(kube_paths))}
+```
+
+Replace `render_env`'s inline kube-path block (unchanged single-node contract,
+delegating to `render_kube_env` — **note:** `render_env` itself is still
+alive at this point in the plan; Task 3 lands before Task 5 deletes it
+entirely, so this edit is a normal in-place change here, not a preview of the
+deletion). **This also deletes the now-unused `kube_paths: list[str] = []`
+accumulator declaration at `envrender.py:49`** — the block below never
+appends to it (that accumulation moved into `render_kube_env` above), and
+leaving the declaration in place would fail `ruff check` (unused variable) at
+Task 7's gate:
+
+```python
+ for kname, target in node.kube_targets.items():
+ base = _key(kname)
+ if target.path is None:
+ raise ValueError(f"kube target {kname!r} not materialized; cannot set KUBECONFIG")
+ put(f"TUNSTRAP_{base}_KUBECONFIG", target.path)
+ put(f"TUNSTRAP_{base}_ENDPOINT", target.endpoint)
+
+ for key, value in render_kube_env(output).items():
+ put(key, value)
+ return env
+```
+
+**[R11] Update `predicted_env_keys` to reserve conservatively, not exactly.**
+Unlike `render_kube_env`'s own export (which correctly uses
+`_kube_channel_keys(exact_count)` because it runs *after* real materialization
+and knows the true count), `predicted_env_keys` runs pre-spawn against the
+*input* schema, before any node has connected — so it must not assume the
+declared cardinality will survive to output time. Whenever *any* node
+declares `kube_targets` at all, reserve **all three** kube names
+unconditionally, regardless of exact count:
+
+```python
+def predicted_env_keys(schema: InputSchema) -> set[str]:
+ keys: set[str] = set()
+ if len(schema.nodes) == 1:
+ (node,) = schema.nodes.values()
+ keys.update({"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID"})
+ for tname in node.remote_targets:
+ base = _key(tname)
+ keys.update(
+ {f"TUNSTRAP_{base}_HOST", f"TUNSTRAP_{base}_PORT", f"TUNSTRAP_{base}_ENDPOINT"}
+ )
+ for kname in node.kube_targets or {}:
+ base = _key(kname)
+ keys.update({f"TUNSTRAP_{base}_KUBECONFIG", f"TUNSTRAP_{base}_ENDPOINT"})
+ if any(node.kube_targets for node in schema.nodes.values()):
+ keys.update({"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"})
+ return keys
+```
+
+**Note, still valid:** Task 5 replaces this function's body again — not to
+fix the cardinality logic (already correct here, conservative from the
+start, so no re-fix needed), but because the entire `TUNSTRAP_*` scalar
+half this version still computes (the `if len(schema.nodes) == 1:` block)
+is deleted once the scalar channel itself is removed. The conservative
+kube-reservation line above is what Task 5's later rewrite keeps unchanged
+(it only adds the three survivor scalars in its place of the deleted
+per-target block) — see Task 5 Step 4.
+
+Note the docstring's "Multi-node input injects no scalars at all, so the
+answer there is the empty set" claim (`envrender.py:83-93`, pre-change) is now
+**false** for the kube-channel keys specifically and must be corrected in the
+same edit — it stays true only for the `TUNSTRAP_*` scalar keys.
+
+- [ ] **Step 4: Run to verify pass**
+
+`.venv/bin/pytest tests/unit/test_envrender.py -v`
+Expected: all pass, including every pre-existing case (single-node
+`KUBECONFIG` behaviour is byte-identical to before this task for the
+one-kube-target case, since `_kube_channel_keys(1)` includes `KUBECONFIG`
+exactly as the old unconditional `put("KUBECONFIG", ...)` did).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tunstrap/envrender.py tests/unit/test_envrender.py
+git commit -m "feat(envrender): multi-node kube channel + conditional KUBE_CONFIG_PATH(S) export (#15)"
+```
+
+---
+
+### Task 4: The unified output contract — shape + `render_unified_output` [PIVOT, new]
+
+Pure-function work only: no `cli.py` wiring yet (Task 5), no materialization
+yet (Task 5). This task makes the shape exist and be correctly built from an
+`OutputSchema`; Task 5 makes anything call it.
+
+**Files:**
+- Modify: `tunstrap/schemas.py` (new models), `tunstrap/envrender.py` (new
+ `render_unified_output`, `render_output_var` body rewritten)
+- Test: `tests/unit/test_envrender.py` (new cases), `tests/unit/test_schemas.py`
+ or a new `tests/unit/test_schemas_unified.py` (model tests)
+
+- [ ] **Step 1: Write failing tests**
+
+Add to `tests/unit/test_envrender.py`:
+
+```python
+def test_render_unified_output_shape() -> None:
+ """[R16] Ports become 'host:port' strings; kube becomes
+ {path,context,endpoint} references; fetch_files becomes
+ {path,size,sha256} -- NOT a content_b64 passthrough, corrected from an
+ earlier revision of this test that asserted the opposite ("content_b64
+ IS allowed via fetch_files"); two reserved top-level keys."""
+ out = OutputSchema(
+ connections={
+ "node1": NodeOutput(
+ ports={"service1": 5432},
+ kube_targets={
+ "k3s": _kube_out_full(
+ 7000, "/s/tunnel-data/node1-k3s", context="tunstrap-node1-k3s"
+ )
+ },
+ fetch_files={
+ # [R16] .path is set here because materialization (Task 5)
+ # runs before render_unified_output ever sees this object --
+ # the daemon writes the bytes and sets .path, exactly as it
+ # already does for KubeTargetOutput.path today.
+ "hosts": FetchedFile(
+ content_b64="aG9zdHM=", size=6, sha256="ab" * 32,
+ path="/s/tunnel-data/node1-hosts",
+ )
+ },
+ )
+ },
+ pid=42,
+ session_dir="/s",
+ started_at="2026-08-07T00:00:00Z",
+ )
+ unified = render_unified_output(out)
+ assert unified["session"] == {
+ "session_dir": "/s",
+ "pid": 42,
+ "started_at": "2026-08-07T00:00:00Z",
+ "warnings": [],
+ }
+ node = unified["nodes"]["node1"]
+ assert node["ports"] == {"service1": "127.0.0.1:5432"}
+ assert node["kube"]["k3s"] == {
+ "path": "/s/tunnel-data/node1-k3s",
+ "context": "tunstrap-node1-k3s",
+ "endpoint": "https://127.0.0.1:7000",
+ }
+ # [R16] {path, size, sha256} exactly -- no content_b64 in the projection.
+ assert node["fetch_files"]["hosts"] == {
+ "path": "/s/tunnel-data/node1-hosts", "size": 6, "sha256": "ab" * 32,
+ }
+ # Nothing that could carry raw content -- kube credentials AND fetched
+ # file content_b64 -- ever appears anywhere in the shape. [R16] content_b64
+ # joins this leak check; it is no longer a sanctioned exception.
+ dumped = json.dumps(unified)
+ for leaked in ("client_certificate_data", "client_key_data", "content_b64"):
+ assert leaked not in dumped
+
+
+def test_render_unified_output_multi_node() -> None:
+ """Node dimension is a nested key: two nodes, two independent bodies."""
+ out = OutputSchema(
+ connections={
+ "a": NodeOutput(ports={"db": 1}),
+ "b": NodeOutput(ports={"db": 2}),
+ },
+ pid=1, session_dir="/s", started_at="now",
+ )
+ unified = render_unified_output(out)
+ assert set(unified["nodes"]) == {"a", "b"}
+ assert unified["nodes"]["a"]["ports"]["db"] == "127.0.0.1:1"
+ assert unified["nodes"]["b"]["ports"]["db"] == "127.0.0.1:2"
+
+
+def test_render_output_var_serializes_the_unified_shape() -> None:
+ """render_output_var's return value decodes to the same shape render_unified_output builds."""
+ out = OutputSchema(
+ connections={"h": NodeOutput(ports={"db": 1})},
+ pid=1, session_dir="/s", started_at="now",
+ )
+ decoded = json.loads(render_output_var(out))
+ assert decoded == render_unified_output(out)
+```
+
+(`_kube_out_full` is a small extension of the file's existing `_kube_out`
+helper that also accepts a `context` kwarg — add it alongside `_kube_out`,
+do not change `_kube_out`'s existing signature, since Task 3's tests still
+use it unchanged.)
+
+- [ ] **Step 2: Run to verify failure**
+
+`.venv/bin/pytest tests/unit/test_envrender.py -k "unified" -v`
+Expected: FAIL — `render_unified_output` missing; `render_output_var` still
+returns the old `RunKubeTarget`-projection shape.
+
+- [ ] **Step 3: Implement**
+
+Add models to `tunstrap/schemas.py` (placed there, not `envrender.py`,
+matching the existing convention that `schemas.py` is "Single source of JSON
+shape" per its own module docstring, `schemas.py:1`):
+
+```python
+class UnifiedKubeRef(BaseModel):
+ """Kube reference in the unified output: never credentials, never content."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ path: str | None
+ context: str
+ endpoint: str
+
+
+class UnifiedSession(BaseModel):
+ """Session metadata block of the unified output."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ session_dir: str
+ pid: int
+ started_at: str
+ warnings: list[TunnelWarning] = Field(default_factory=list)
+
+
+class UnifiedFetchRef(BaseModel):
+ """[R16] Fetched-file reference in the unified output: never content_b64,
+ mirroring UnifiedKubeRef's own credential/content narrowing. Success and
+ error are mutually exclusive, matching FetchedFile's own xor -- but this
+ model has no validator enforcing it, because render_unified_output (below)
+ is the only place that constructs one, from an already-validated
+ FetchedFile, per exactly the same explicit-keyword-construction pattern
+ UnifiedKubeRef already uses instead of a second runtime check."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ path: str | None = None
+ size: int | None = None
+ sha256: str | None = None
+ error: str | None = None
+
+
+class UnifiedNode(BaseModel):
+ """One node's body in the unified output: ports, kube refs, fetch_files."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ ports: dict[str, str] = Field(default_factory=dict)
+ kube: dict[str, UnifiedKubeRef] = Field(default_factory=dict)
+ fetch_files: dict[str, UnifiedFetchRef] = Field(default_factory=dict)
+
+
+class UnifiedOutput(BaseModel):
+ """The entire consumer-facing output: two reserved top-level keys."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ session: UnifiedSession
+ nodes: dict[str, UnifiedNode]
+```
+
+Add to `tunstrap/envrender.py`:
+
+```python
+def render_unified_output(output: OutputSchema) -> dict[str, Any]:
+ """Build the unified, node-qualified structure (design doc, "Unified
+ output contract"). Ports become 'host:port' strings; kube becomes
+ {path, context, endpoint} references (never credentials, never content,
+ per RunKubeTarget's pre-existing allow-list — this reshapes, not
+ reprojects). [R16] fetch_files becomes {path, size, sha256} (or
+ {error}) -- NOT a passthrough. An earlier revision of this function (and
+ this docstring) carried forward the pre-#15 design's decision to let
+ fetch_files ride unprojected, including content_b64; R16 retracts that
+ for the same reason U4 already narrowed kube: content must not enter a
+ Terraform variable or the materialized file, only its path/metadata may.
+ Callers must ensure fetch_files entries are already materialized (.path
+ set) before calling this -- see Task 5's fetched-file materialization
+ step, which runs upstream of this function, the same ordering
+ KubeTargetOutput.path already requires today.
+ """
+ nodes: dict[str, object] = {}
+ for node_name, node in output.connections.items():
+ kube = {
+ kname: UnifiedKubeRef(
+ path=target.path, context=target.context_name, endpoint=target.endpoint
+ ).model_dump()
+ for kname, target in node.kube_targets.items()
+ }
+ ports = {tname: f"127.0.0.1:{port}" for tname, port in node.ports.items()}
+ fetch_files = {
+ fname: (
+ UnifiedFetchRef(error=f.error).model_dump(exclude_none=True)
+ if f.error is not None
+ else UnifiedFetchRef(path=f.path, size=f.size, sha256=f.sha256)
+ .model_dump(exclude_none=True)
+ )
+ for fname, f in node.fetch_files.items()
+ }
+ nodes[node_name] = UnifiedNode(
+ ports=ports, kube=kube, fetch_files=fetch_files
+ ).model_dump()
+ session = UnifiedSession(
+ session_dir=output.session_dir,
+ pid=output.pid,
+ started_at=output.started_at,
+ warnings=output.warnings,
+ ).model_dump(mode="json")
+ return {"session": session, "nodes": nodes}
+```
+
+**`exclude_none=True`, deliberately**: without it, a success entry would
+serialize `{"path": ..., "size": ..., "sha256": ..., "error": null}` — a
+stray `"error": null` in every successful fetch, not matching the design
+doc's shape (`{"path", "size", "sha256"}` exactly, no fourth key) or the
+error-branch shape (`{"error"}` exactly, not `{"path": null, "size": null,
+"sha256": null, "error": ...}`). This mirrors why `UnifiedKubeRef` does not
+need the same treatment: none of its three fields is ever optional/`None`
+in a materialized `KubeTargetOutput`.
+
+Replace `render_output_var`'s body (keep the signature, `OutputSchema -> str`
+— no `cli.py` call-site change needed):
+
+```python
+def render_output_var(output: OutputSchema) -> str:
+ """Serialise the unified structure for ``--output-var``.
+
+ Delivers the same content the materialized file carries (Task 5) — see
+ docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md, "The
+ unified output contract", for the delivery/stability contract governing
+ which of the two a plan-safe consumer should actually bind to a resource.
+ """
+ return json.dumps(render_unified_output(output), separators=(",", ":"))
+```
+
+Delete the old `RunKubeTarget`-based body (the `payload = output.model_dump
+(mode="json")` / per-node `RunKubeTarget.model_validate` loop) — it is fully
+replaced, not kept as a fallback.
+
+**`RunKubeTarget` disposition:** now unused by `render_output_var`. Check with
+`vulture` (Task 7) whether anything else still imports it; if not, delete the
+class from `schemas.py` too — its whole purpose (an allow-list projection for
+this exact channel) is now served by `UnifiedKubeRef`, and keeping an unused
+allow-list model around is exactly the kind of drift a reviewer would flag on
+sight.
+
+- [ ] **Step 4: Run to verify pass**
+
+`.venv/bin/pytest tests/unit/test_envrender.py -v`
+Expected: all pass. The **old** `render_output_var` shape tests in
+`tests/unit/test_cli_run_output_var.py` (e.g.
+`test_output_var_carries_the_whole_envelope_minus_kube_credentials`) now
+fail, expectedly — they pin the old `connections..ports.`
+(int) shape; Task 5 retargets them alongside the rest of that file's
+scalar-removal changes. Do not fix them here; note the expected failures and
+move on, matching this plan's own established discipline of one clean
+commit per concern rather than a half-finished retarget.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tunstrap/schemas.py tunstrap/envrender.py tests/unit/test_envrender.py
+git commit -m "feat(envrender): unified node-qualified output contract, shape only (#15)"
+```
+
+---
+
+### Task 5: Materialize the unified output; remove the scalar channel [PIVOT, new + rewrite; R16, iteration 7 adds fetch-file materialization]
+
+**This is the big ripple task.** It does six things in one coherent change,
+because they are the same edit site (`_build_child_env` and its callers) or
+its direct sibling (the daemon-side materialization step):
+(a) wires `render_unified_output`/`render_output_var` into `run` and adds
+unconditional materialization (using the repo's existing secure-write
+primitive, not a write-then-chmod sequence); (b) collapses the kube-channel
+call to unconditional (supersedes iteration 2's two-branch design, decision
+history entry 13); (c) deletes `render_env`, `MultiNodeEnvUnsupported`, and
+`inject_scalars`; (d) **re-scopes** (not deletes) the `predicted_env_keys`
+anti-drift guard to compare against `_build_child_env`'s actual output, since
+`render_env` is no longer there to compare against; (e) retargets every
+pre-existing test, fixture, and shipped artifact this removal breaks, across
+every tier — enumerated exhaustively below, not case-by-case; (f) **[R16,
+new]** materializes `fetch_files` content to `tunnel-data/-`
+the same way kube files already are, removing `content_b64` from every
+consumer-facing projection — see "Fetched-file materialization" below, its
+own dedicated blast-radius enumeration.
+
+**Iteration-4 note, read before starting this task.** The first three review
+rounds each caught this task's blast radius incompletely, one spot at a time
+— a test here, a stale reference there, an entire test *file*
+(`test_cli_run_output_var_projection.py`) missed twice, integration and e2e
+fixtures never looked at. The table below is a full grep-driven enumeration
+across `tunstrap/`, `tests/unit`, `tests/integration`, `tests/e2e`, and
+`docs/`, done once, systemically, specifically so this task cannot be landed
+piecemeal again. **Do not treat this table as a starting point to extend by
+inspection — treat it as complete; if the implementer finds something it
+missed, that is itself a signal to re-run the greps below, not to patch the
+one spot found.**
+
+Re-derivable with (or equivalent):
+
+```bash
+grep -rn 'TUNSTRAP_[A-Z0-9_]*' tunstrap/ tests/ docs/ \
+ --include='*.py' --include='*.md' --include='*.tf' \
+ | grep -vE 'TUNSTRAP_SESSION_DIR|TUNSTRAP_PID|TUNSTRAP_OUTPUT_FILE|TUNSTRAP_INPUT|TUNSTRAP_E2E_REQUIRE_ALL|TUNSTRAP_TOKEN'
+grep -rn 'MultiNodeEnvUnsupported\|inject_scalars\|render_env(' tunstrap/ tests/ --include='*.py'
+grep -rn 'connections\.' tests/ docs/ --include='*.py' --include='*.md' --include='*.tf'
+grep -rn '\["connections"\]\|\.connections\[' tests/ --include='*.py'
+```
+
+### Blast-radius table (authoritative; every hit below has a disposition)
+
+**Unit tier:**
+
+| File:line | Old shape/symbol | Disposition |
+|---|---|---|
+| `test_cli_run.py:91` | `FakePopen.last_env["TUNSTRAP_DB_PORT"] == "5432"` | Retarget: assert `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`/`TUNSTRAP_OUTPUT_FILE` present, `TUNSTRAP_DB_PORT` absent. |
+| `test_cli_run_input_env_scrub.py:156` | `env["TUNSTRAP_DB_PORT"] == "5432"`, "the injected scalars must survive the scrub" | Retarget: assert `TUNSTRAP_SESSION_DIR` survives the scrub instead; same docstring claim, different scalar. |
+| `test_cli_run_input_env_scrub.py:174` (found beyond the drill's list) | `json.loads(env[VAR])["pid"] == 99` | Retarget: `json.loads(env[VAR])["session"]["pid"] == 99` — `pid` moved under the unified structure's `session` key. |
+| `test_cli_runner.py:392` (+docstring at ~360) | `"export TUNSTRAP_DB_PORT='5432'" in res.output` — existing `start --output env` pin | **Fix the existing assertion**, not just "add a test": replace with the new three-survivors-plus-kube-channel export set; drop `TUNSTRAP_DB_PORT`/`TUNSTRAP_WEB_PORT`-style lines from any fixture the test builds. |
+| `test_cli_run_postspawn.py:955,993` (`test_lone_optional_node_failure_keeps_its_own_exit_code`) | Asserts `error["error"] == "MultiNodeEnvUnsupported"` for a lone optional node's failure (`connections == {}` trips `render_env`'s `!= 1` guard today) | Retarget completely, new behaviour is the opposite: `_build_child_env` no longer branches on connection count at all, so this now **succeeds** (exit 0). Rename to `test_lone_optional_node_failure_still_succeeds_with_only_a_warning`; assert exit 0, `session.warnings` (via `--output-var`) carries the "edge" failure, teardown ran exactly once. |
+| `test_cli_run_output_var.py` (multiple) | See Task 5 Step 2's existing per-test list below — **unchanged by this iteration's fix**, already correct from iteration 3. | Retarget/delete per the existing list (kept). |
+| `test_cli_run_output_var_projection.py` (**whole file, missed in iterations 1-3**) | `RunKubeTarget` import; `["connections"]["node"]["kube_targets"]["k3s"]`; `decoded["pid"]`/`["session_dir"]`/`["started_at"]`/`["connections"]["node"]["ports"]` | See dedicated sub-section below — this is a security-critical file (credential-scrubbing pin) and needs care, not a one-line note. |
+| `test_envrender.py:4` | `from tunstrap.exceptions import MultiNodeEnvUnsupported` | Delete the import — `ruff` F401 once every user of it in this file is gone. |
+| `test_envrender.py` (Task-3-era, `render_env`-dependent) | `test_render_ports_and_session`, `test_render_kube_sets_kubeconfig`, `test_render_kube_not_materialized_raises`, `test_render_requires_single_node_zero`, `test_render_requires_single_node_two`, **and explicitly `test_render_kube_env_works_for_multi_node_while_render_env_still_rejects`** (added by Task 3 itself, plan line ~360 — the general clause below missed naming this one by name in earlier iterations) | Delete all six — each asserts on `render_env`, which no longer exists. |
+| `test_envrender.py::test_predicted_env_keys_matches_render_env` | Compares `predicted_env_keys` against `render_env`'s output | **Retarget, not delete** (the major fix this iteration exists to make) — see "Anti-drift guard" sub-section below. |
+| `test_envrender.py` (predicted_env_keys shape) | `test_predicted_env_keys_no_kube_omits_kubeconfig`, `test_predicted_env_keys_multi_node_is_empty` | Delete — both pin the old per-target scalar enumeration / the "multi-node is empty" claim, which is false under the new unconditional `{session scalars} ∪ kube-channel` contract; superseded by Task 5's own new `test_predicted_env_keys_is_session_scalars_plus_kube_channel` (below, update its expected set to include `TUNSTRAP_OUTPUT_FILE`) and `test_predicted_env_keys_no_kube_is_just_the_two_survivors` (rename: three survivors now). |
+| `test_exceptions.py:87-90` | `test_multinode_env_unsupported_is_a_tunstrap_error`-shaped subclass test | Delete. |
+| `test_exceptions.py:94-99` | Exit-code + envelope test constructing `MultiNodeEnvUnsupported(...)` | Delete. |
+| `test_exceptions.py:107-114` | `_EXIT_CODES[MultiNodeEnvUnsupported] == 1` table test | Delete. All **three** cases named explicitly — "whatever case pins the exit code" undercounted them twice already. |
+| `test_tofu_proxy.py:375,386,397,399` | Docstrings framing the pop in terms of `inject_scalars`/`render_env` | Update docstrings only (mechanism note, not an assertion change) — both `test_tunnelled_suppresses_kubeconfig_in_child_env` and `test_tunnelled_drops_an_inherited_kubeconfig_in_the_multi_node_case`; already flagged in Task 5's earlier draft, kept here for completeness of the table. |
+
+**Integration tier (Task 7 Step 3's "no changes needed" claim was false — corrected here and in Task 7):**
+
+| File:line | Old shape/symbol | Disposition |
+|---|---|---|
+| `test_run_env_io.py:49-50` (`_PROBE_SINGLE`) | `os.environ["TUNSTRAP_WEB_PORT"]` | Retarget: probe reads `json.load(open(os.environ["TUNSTRAP_OUTPUT_FILE"]))["nodes"]["hub"]["ports"]["web"]` (a `"host:port"` string; `.rsplit(":", 1)[1]` for the port) instead. |
+| `test_run_env_io.py` `_PROBE_MULTI` (same region, `envelope["connections"]`) | `envelope["connections"][name]["ports"]["web"]` | Retarget to `envelope["nodes"][name]["ports"]["web"]` (string, parse as above). |
+| `test_run_env_io.py` `_PROBE_MULTI` leak check | `k.startswith("TUNSTRAP_") and k != "TUNSTRAP_INPUT"` → now **wrongly** flags the three sanctioned survivors as leaks | Retarget: exclude `TUNSTRAP_SESSION_DIR`, `TUNSTRAP_PID`, `TUNSTRAP_OUTPUT_FILE` too. |
+| `test_run_env_io.py:173-193` (`test_multi_node_without_output_var_is_exit_1`) | Asserts exit 1 + `MultiNodeEnvUnsupported`, `not session_dir.exists()` | **Retarget completely — the exact behaviour this task inverts.** Rename to `test_multi_node_without_output_var_now_succeeds`; assert exit 0, no `MultiNodeEnvUnsupported` anywhere in stderr (stderr may be empty), teardown ran. Materialization's *content* is not re-verified here — that is `test_cli_run_materialize.py`'s job at unit level; this integration test's remaining job is confirming the real console script also allows the case, not re-proving the file's shape. |
+| `test_cli_modes.py:111-138` | `start --output env`'s `TUNSTRAP_WEB_PORT`/`TUNSTRAP_WEB_ENDPOINT`; `run`'s child probe reading `os.environ['TUNSTRAP_WEB_PORT']` directly | Retarget both (two separate tests in this range): the `start --output env` test asserts `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_OUTPUT_FILE` present, `TUNSTRAP_WEB_PORT`/`_ENDPOINT` absent, and derives the port via `json.load(open(env["TUNSTRAP_OUTPUT_FILE"]))["nodes"][...]["ports"]["web"]`; the `run` child probe (inline Python string) rewrites to read `TUNSTRAP_OUTPUT_FILE` the same way instead of `TUNSTRAP_WEB_PORT` directly. |
+
+**E2E tier + shipped artifacts (in scope — this ships with the work; deferring is not an option, per the ruling, because the failure mode is silent: `try()` around `jsondecode` swallows the shape mismatch into an empty `config_path` and the resulting error is a confusing provider message, not an obvious test failure):**
+
+| File:line | Old shape/symbol | Disposition |
+|---|---|---|
+| `tests/e2e/module/main.tf:27-28` | `try(jsondecode(var.tunstrap), { connections = {} })`; `local.tunnel.connections.node.kube_targets.k3s.path` | Retarget: `{ nodes = {} }`; `local.tunnel.nodes.node.kube.k3s.path`. Task 6 (extended). |
+| `docs/recipe_terragrunt.md:287-288` | Same `tunnel`/`kubepath` locals, mirroring `main.tf` | Retarget identically. Task 6. |
+| `docs/recipe_terragrunt.md:~329` | Prose: "`path` comes from... `connections.*.kube_targets.*.path`" | Retarget prose to `nodes.*.kube.*.path`. Task 6. |
+| `docs/recipe_terragrunt.md:~407` | Prose: "the module picks the node out of `connections[]`" | Retarget to `nodes[]`; also correct the surrounding paragraph's claim that multi-node suppresses the scalar/`KUBECONFIG` channel — under the pivot the kube channel is unconditional and the "TUNSTRAP_* env... not injected" framing is stale. Task 6. |
+| `docs/recipe_terragrunt.md:~509` | "What is proven" section, `--output-var` → `TF_VAR_tunstrap` → `try(jsondecode(...))` → `config_path` chain description | Mechanism description stays accurate; no shape-specific text to fix beyond confirming it still reads correctly once the two locals above change. Verify only. |
+| `tests/e2e/rig.py:171` | Docstring: "`module/main.tf` decodes `connections.node.kube_targets.k3s.path`" | Retarget docstring text to `nodes.node.kube.k3s.path`. |
+| `tests/e2e/test_tofu_providers.py:154` | `envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` | Retarget to `envelope["nodes"]["node"]["kube"]["k3s"]["path"]`. |
+| `tests/e2e/test_tofu_providers.py:251-254` | Fake envelope dict literal: `{"connections": {"node": {"ports": {}, "kube_targets": {"k3s": {...}}}}}` | Retarget the literal to `{"nodes": {"node": {"ports": {}, "kube": {"k3s": {"path": ..., "context": ..., "endpoint": ...}}}}}` — align field names with `UnifiedKubeRef` (drop any fields beyond `path`/`context`/`endpoint` the old literal happened to include). |
+| `tests/e2e/test_terragrunt_apply.py:339,425` | `envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` (×2, apply and tunnelled-output cases) | Retarget both to `envelope["nodes"]["node"]["kube"]["k3s"]["path"]`. |
+| `tests/e2e/test_rig.py:278` | `envelope["connections"]["node"]["kube_targets"]["k3s"]` | **Unaffected, disposition = out of scope, stated explicitly, not silently skipped:** this reads `tunstrap start`'s **raw stdout JSON** (`OutputSchema.model_dump_json()`-shaped), not the `--output-var`/materialized unified channel. The pivot's scope is `run`'s consumer-facing channels (`--output-var`, materialization) and `start --output env`; `start`'s default/`--output json` stdout — documented since the pre-#15 design as "the complete envelope," a separate contract for session-management tooling, not consumer transformation — is deliberately untouched. Judgment call, recorded here since it narrows the blast radius meaningfully; if a reviewer wants `start`'s raw JSON unified too, that is a new decision, not an oversight. |
+| `tests/e2e/test_recipe_terragrunt.py:259,322` | Recipe↔module drift guard (textual block comparison) | **Unaffected in mechanism.** The guard's compared *content* changes automatically once `main.tf` and the recipe are both updated to the `nodes.*` shape in Task 6 — no separate code change to the guard itself. Task 6's own steps must keep it green (run it as part of Task 6's verification, not just Task 7's). |
+
+### `test_cli_run_output_var_projection.py` — dedicated retarget (security-critical, missed twice before this iteration)
+
+This file pins the credential-scrubbing property for the projected kube
+reference — it must not be weakened while being reshaped. All four tests
+retarget or delete, **not** left alone:
+
+- `test_output_var_never_carries_kube_private_key_material` — retarget the
+ shape lookup: `json.loads(env["TF_VAR_tunstrap"])["nodes"]["node"]["kube"]["k3s"]`
+ instead of `["connections"]["node"]["kube_targets"]["k3s"]`. The
+ absence assertions (`client_key_data`/`client_certificate_data`/
+ `content_b64` not in `target`) are unaffected in spirit, but note **the
+ field set is now smaller than before for a different reason too** — see
+ the next test.
+- `test_output_var_keeps_every_field_the_consumer_chain_reads` — the
+ anti-vacuity pair. **The expected dict shrinks further than credential
+ removal alone**: `UnifiedKubeRef` carries exactly `{path, context,
+ endpoint}` (Task 4's model) — `cluster_name`, `local_port`,
+ `tls_server_name`, and `certificate_authority_data` (all present in the old
+ `RunKubeTarget` projection, none of them credentials) are **also** gone
+ under the unified shape, because the design narrows to references only
+ (design doc, U4). Retarget the expected dict to exactly
+ `{"path": KUBE_PATH, "context": "probe-context", "endpoint":
+ "https://127.0.0.1:41111"}`. This is a real, intentional narrowing beyond
+ the credential fix — call it out in the retargeted test's docstring so a
+ future reader does not mistake it for scope creep.
+- `test_output_var_projection_leaves_the_rest_of_the_envelope_intact` —
+ retarget: `decoded["pid"]` → `decoded["session"]["pid"]`,
+ `decoded["session_dir"]` → `decoded["session"]["session_dir"]`,
+ `decoded["started_at"]` → `decoded["session"]["started_at"]`,
+ `decoded["connections"]["node"]["ports"]` →
+ `decoded["nodes"]["node"]["ports"]` — **and note the value shape changed
+ too**: `{"db": 5432}` (int) becomes `{"db": "127.0.0.1:5432"}` (string).
+- `test_projection_is_an_allow_list_so_a_new_secret_field_cannot_leak` —
+ **delete, not retarget.** This test validated `RunKubeTarget.model_validate`
+ directly, exercising `extra="ignore"`'s fail-closed behaviour against an
+ untrusted dict. `RunKubeTarget` is deleted (Task 4's disposition); its
+ replacement, `render_unified_output`, never calls `.model_validate()` on
+ untrusted kube data at all — it constructs `UnifiedKubeRef(path=...,
+ context=..., endpoint=...)` with three explicit keyword arguments, so a
+ hypothetical field added to `KubeTargetOutput` later cannot leak through
+ without someone editing that constructor call by hand. The allow-list
+ property now holds **by construction**, not by validating against a model,
+ so there is nothing left for a `model_validate`-shaped test to exercise
+ differently from `test_output_var_keeps_every_field_the_consumer_chain_
+ reads`'s own exact-equality assertion, which already proves the same
+ property end-to-end. Confirm this by re-reading `render_unified_output`'s
+ body (Task 4) before deleting — the property must actually hold, not just
+ be asserted to hold by this note.
+
+### Fetched-file materialization + `content_b64` blast-radius enumeration [R16, new]
+
+**New mechanism, same precedent as kube.** `FetchedFile` (`schemas.py:292-313`)
+gains `path: str | None = None`, mirroring `KubeTargetOutput.path`
+(`schemas.py:317-336`) exactly. Wherever kube materialization currently runs
+daemon/worker-side (the same call site the "Materialization write mechanism"
+design-doc section and this task's `output.json` writer both point at —
+confirm the exact function before implementing, do not assume it is
+`manager.py:start_all_and_build_output` without checking), add a parallel
+step: for each successful `FetchedFile` a node's `fetch_files` produced,
+base64-decode `content_b64` and write the raw bytes to
+`tunnel-data/-` using the **same atomic-replace primitive**
+as `output.json` (temp file + `O_EXCL` + `os.replace`, not `_write_file`'s
+`O_TRUNC` — see "Materialization write mechanism," design doc), then set
+`.path`. A failed fetch (`.error` set) materializes nothing. `content_b64`
+itself is **not** deleted from `FetchedFile` — it stays internal plumbing,
+same as kube's own `content_b64`. The projection itself (`{path, size,
+sha256}`/`{error}`, no `content_b64`) is **not** a separate function here —
+it is `render_unified_output`'s own `fetch_files` construction via the new
+`UnifiedFetchRef` model, given in full in Task 4's `render_unified_output`
+body (above); this materialization step is what makes `.path` non-`None` by
+the time that function runs, the same ordering `KubeTargetOutput.path`
+already requires and Task 4's own docstring now states explicitly.
+
+**`content_b64` grep enumeration, repo-wide, every hit dispositioned** (per
+this plan's established discipline — a table, not a promise to look later).
+**[R16, iteration 8 — methodology correction.]** An earlier revision of this
+enumeration ran the grep as `tunstrap/ tests/ --include='*.py'` only,
+dropping the `docs/` tier and `--include='*.md'`/`'*.tf'` that iteration 4
+established for the *original* blast-radius table (top of Task 5) and that
+this R16-specific enumeration should have inherited rather than narrowing.
+Consequence: `docs/recipe_terragrunt.md`'s own shipped "Fetched files are
+exported verbatim, not projected" subsection — which argues the *opposite*
+of R16 in prose — went unfound by a whole review round. Re-run at the
+established scope, not the narrowed one:
+
+```bash
+grep -rn 'content_b64' tunstrap/ tests/ docs/ --include='*.py' --include='*.md' --include='*.tf'
+```
+
+**Kube-internal — unaffected by R16, listed to prove they were checked, not
+missed:** `KubeTargetOutput.content_b64` (`schemas.py:335`) is a different
+field entirely (the patched kubeconfig's own content, unrelated to
+`fetch_files`) and is untouched by this ruling. Every hit below reads or
+constructs *that* field, not `FetchedFile`'s: `test_kube_run.py:111`,
+`test_envrender.py:20`, `test_output_kube.py:35`, `test_tofu_proxy.py:351`,
+`test_kube_targets.py:91,147` (integration — reads `start`'s raw stdout JSON,
+already out of scope per the existing carve-out), and the **absence**
+assertions for kube's own `content_b64` in
+`test_cli_run_output_var.py:256,281` and
+`test_cli_run_output_var_projection.py:10,72,91,190,249` (these already
+correctly assert kube's `content_b64` is *not* in the projected shape —
+nothing to change).
+
+**`fetch_files`-related — in scope, retarget:**
+
+| File:line | Old shape/behaviour | Disposition |
+|---|---|---|
+| `test_manager_fetch.py:91` (`test_fetch_files_results_populate_node_output`) | Docstring "Fetcher results land in `NodeOutput.fetch_files` unchanged"; fixture `FetchedFile(content_b64="YQ==", size=1, sha256="ca97")`, no `path` | **False under R16** — a materialization step now runs after the fetch. Retarget: assert `out.connections["a"].fetch_files["kubeconfig"].path` is set to the expected `tunnel-data/a-kubeconfig` location and its on-disk bytes match `base64.b64decode("YQ==")`; `content_b64` still present on the object (internal plumbing, unchanged) but the test's point moves to `path`. Rename to drop "unchanged" from the docstring. |
+| `test_fetcher_unit.py:101,111` | `fetcher.fetch_files()`'s own unit test, asserts `ff.content_b64` set on success | **Unaffected** — this is the SSH-fetch-to-memory layer, upstream of the new daemon-side materialization step; `fetcher.py` itself is not changed by R16, only its caller gains a new step after it. |
+| `test_fetch_files.py:67,119,216` (integration) | `base64.b64decode(ff["content_b64"])` reading the raw `start` stdout envelope | **Unaffected in mechanism** (raw stdout stays the "complete envelope," existing carve-out) **but verify against the correct channel**: if any of these three actually assert against `--output-var`/materialized output rather than raw `start` JSON, that specific assertion retargets to read `ff["path"]` + a direct file read instead — confirm which channel each of the three actually exercises before deciding no change is needed; do not assume all three are raw-stdout without checking. |
+| `test_fetch_security.py:49,52,69,87-89` (integration) | Proves fetched content_b64 "appears on stdout only, never on stderr" — i.e. accepts it riding *some* channel, checks which | **Retarget the property proved, not just the assertion syntax.** R16 makes a stronger claim possible: fetched content should appear **nowhere** in `--output-var`/the materialized manifest, only in the `0600` on-disk file. Rewrite to assert (a) `content_b64`/the raw fetched bytes do not appear anywhere in `TF_VAR_tunstrap`, the materialized `output.json`, stdout, or stderr; (b) the file at the reported `path` exists, is mode `0600`, and its bytes match the source. This is a **stronger** security property than the test proved before, not a weaker one — call that out in the retargeted test's docstring. |
+| `test_cli_run_output_var.py:83` (`_RICH_PAYLOAD`) | `"fetch_files": {"hosts": {"content_b64": "aG9zdHM=", "size": 6, "sha256": "ab" * 32}}` | Retarget the fixture to `{"hosts": {"path": "/s/tunnel-data/node-hosts", "size": 6, "sha256": "ab" * 32}}` — this fixture feeds `test_output_var_keeps_every_field_the_consumer_chain_reads`-style field-preservation tests (the R16 instruction's explicit callout); any downstream assertion reading `fetch_files.hosts.content_b64` from the decoded var retargets to `.path`. |
+
+**`docs/` tier — the rows the narrowed grep missed, added here:**
+
+| File:line | Old shape/behaviour | Disposition |
+|---|---|---|
+| `docs/recipe_terragrunt.md:344` | Kube-drop list: "and **drops** `client_key_data`... `content_b64`... `client_certificate_data`" | **Verify only, no rewrite.** This states kube's `content_b64` is dropped from `TF_VAR_tunstrap`'s kube projection — still true under R16 (unrelated field, U4's narrowing was already in force). Confirmed accurate as written. |
+| `docs/recipe_terragrunt.md:361-364` | "`tunstrap start` is not affected: it writes the complete envelope to stdout... without `--materialize` its `content_b64` is the only way to obtain the kubeconfig at all." | **Verify only, no rewrite.** Matches the existing, unchanged scope carve-out (design doc, "Compatibility") — `start`'s raw default JSON stdout is untouched by R16, for kube and (per "Fetched-file materialization," design doc) for `fetch_files` alike. Confirmed accurate. |
+| `docs/recipe_terragrunt.md:366-388` (whole subsection, "### Fetched files are exported verbatim, not projected") | Argues the **opposite** of R16: "Every `fetch_files` entry keeps its `content_b64` whole"; "`FetchedFile` has no `path` (`schemas.py:292`), so dropping `content_b64` would be a silent, unrecoverable breakage"; "tunstrap fetches into the envelope (`content_b64`), not onto disk"; the materialize-then-drop end-state "is recorded in the spec's Out of scope" (false — the issue15 design's "Out of scope" does not list it; this premise is now simply wrong, not aspirational) | **REWRITE — the whole subsection.** See Task 6's new step, below, for the replacement text. This is the finding the narrowed grep missed. |
+| `tests/e2e/module/main.tf:13` | Comment: "the kube target's `client_key_data`, `client_certificate_data` and `content_b64` are dropped" | **Unaffected by R16** (kube-only, unrelated field) — already inside the region Task 6 Step 0's existing shape-migration row for this file covers for the unrelated `connections.*`→`nodes.*` rename; no R16-specific change. |
+| Untracked characterization harness | Fetch/kube fixtures using `content_b64` | **Out of scope, stated explicitly.** It is not part of the test suite, shipped code, or consumer documentation. |
+| `docs/specs/2026-05-20-feature-fetch-files-design.md`, `docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`, `docs/specs/2026-08-03-run-env-io-decision-history.md`, `docs/specs/2026-05-30-kube-targets-design.md`, `docs/superpowers/plans/2026-06-25-cli-run-modes.md`, `docs/superpowers/plans/2026-05-30-kube-targets.md` | Pre-#15 design/decision/plan documents for already-shipped tickets (#14 and earlier), predating this ticket by weeks | **Out of scope, historical record — cited, never edited**, matching this plan's own established treatment of the pre-#15 decision history everywhere else in this document (e.g. "the pre-#15 decision history's `fetch_files[*].content_b64` entry," cited by name, never rewritten). Editing a completed ticket's own historical spec to match a later ticket's decision would falsify the historical record of what that ticket actually shipped. |
+| Untracked superseded owner-tracking design | — | **Out of scope** — historical scratch material, not live. |
+| Untracked issue #15 spike notes | Kube-only `content_b64` hits (patched-kubeconfig content in the collision-test prototype) | **Unaffected by R16** (kube, not `fetch_files`) and a frozen historical spike snapshot. |
+| `test_output_schema.py:25,32,46,63` | `FetchedFile(content_b64=...)` construction, xor-validation tests | **Unaffected** — these test `FetchedFile`'s own model validation (`content_b64`/`error` xor), which is unchanged; only a new optional `path` field is added, not a change to this xor. Add one new case: `path` defaults to `None`, is not part of the xor, and can be set independently after construction (mirrors `KubeTargetOutput.path`'s own test coverage, if any — check for a precedent test to mirror rather than inventing a new assertion style). |
+
+**Schema note:** `FetchedFile`'s xor validator (`schemas.py:303-314`) does not
+need new logic for `path` — it is a plain optional field set post-construction
+by the new materialization step, the same relationship `KubeTargetOutput.path`
+already has to that model's own required fields. Confirm this against the
+actual `KubeTargetOutput` definition before implementing, not assumed from
+this note alone.
+
+**`predicted_env_keys`/anti-drift guard: checked, unaffected by R16.**
+`TUNSTRAP_OUTPUT_FILE` was already one of the three unconditional survivors
+`_build_child_env` injects and `predicted_env_keys` reserves for **`run`**,
+not only for `start --output env` (see "The scalar channel is removed,"
+design doc, and this task's own `predicted_env_keys` rewrite above) — R16's
+"generalize `TUNSTRAP_OUTPUT_FILE` to `run`" instruction describes its
+*role* changing (from a secondary convenience scalar to the *primary*
+consumer-facing locator, now that R9's mode 2 is gone), not its *export
+set membership*, which was already unconditional. No change to
+`predicted_env_keys`'s formula, `_build_child_env`'s injection, or either
+half of the two-part anti-drift guard (R11) is needed for R16 — verified,
+not silently assumed. `fetch_files`'s own keys never touched env at all
+(pre- or post-R16), so the guard's key set is untouched by the materialization
+change above too.
+
+### Anti-drift guard — retargeted, not deleted, and now two-part (R11)
+
+**Standing ruling R1: the guard is extended, never weakened. An earlier
+revision of this task deleted `test_predicted_env_keys_matches_render_env`
+on the false premise that only one implementation of the injected-key set
+remained after this task's rewrite. That premise is wrong**: after Task 5,
+there are still **two independent implementations** of "what keys will `run`
+inject" — `_build_child_env` (hardcodes `TUNSTRAP_SESSION_DIR`/
+`TUNSTRAP_PID`/`TUNSTRAP_OUTPUT_FILE`, merges `render_kube_env`'s output) and
+`predicted_env_keys` (Task 3's conservative formula: the three survivors,
+plus all three kube names whenever any `kube_targets` are declared). If
+these two silently diverge, the pre-spawn `--output-var` collision check
+(`_validate_output_var`, `cli.py:311-324` — confirm the exact line against
+the checked-out file) under-rejects: a NAME that collides with a key
+`_build_child_env` actually injects would sail through validation and then
+genuinely collide post-spawn.
+
+**[R11 — a second, iteration-6 correction to this same guard.]** A *later*
+revision retargeted the guard to a single `predicted_env_keys(schema) ==
+set(actual)` full-equality assertion. That was only valid while
+`predicted_env_keys` computed the *exact* cardinality-conditional key set.
+Task 3 now makes `predicted_env_keys` deliberately **conservative** (reserves
+all three kube names whenever any `kube_targets` exist, regardless of exact
+count), so **exact equality can no longer hold in general** — a schema with
+exactly one kube target that materializes cleanly now predicts all three
+kube names (conservative) while the actual export has only two (`KUBECONFIG`
++ `KUBE_CONFIG_PATH`, the exact `==1` branch) — genuinely, correctly unequal.
+**The guard splits into two independent tests:**
+
+1. **Formula test** (exact equality, unit-test style — proves the
+ *conservative formula itself* is implemented correctly; this is the
+ already-written `test_predicted_env_keys_reserves_all_three_for_one_
+ kube_target` / `..._two_kube_targets_one_node` pair from Task 3, not
+ repeated here).
+2. **Safety-envelope test** (subset, the actual anti-drift guard — proves
+ the conservative reservation still covers whatever *actually* gets
+ injected, even when cardinality shrinks between input and output):
+
+```python
+def test_predicted_env_keys_covers_actual_injected_keys_under_cardinality_shrink(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """[R11] Safety-envelope half of the two-part anti-drift guard: predicted
+ must be a superset of actual, driven by the exact scenario that falsifies
+ a predictor that got the conservatism backwards -- two kube targets
+ DECLARED (one on an optional node that fails), only ONE materializes. A
+ NAME colliding with a key _build_child_env actually injects, but which
+ predicted_env_keys failed to reserve, would sail through the pre-spawn
+ collision check and then genuinely collide post-spawn."""
+ from tunstrap import cli as cli_mod
+ from tunstrap.cli import _build_child_env
+
+ # _build_child_env starts from dict(os.environ) (cli.py:394), so without
+ # isolating it first, `set(actual)` is the whole ambient environment
+ # (PATH, HOME, ...) and any comparison against it is meaningless in any
+ # real process. Isolate BEFORE calling it, not after: subtracting
+ # os.environ back out (`set(actual) - set(os.environ)`) is NOT an
+ # acceptable substitute -- a key that is both inherited AND injected (an
+ # operator-set KUBECONFIG, or a NAME matching --output-var) would be
+ # subtracted away too, silently under-checking exactly the collision
+ # this guard exists to catch.
+ monkeypatch.setattr(cli_mod.os, "environ", {})
+
+ # Input: two kube targets declared, on two nodes -- one optional and about
+ # to fail. predicted_env_keys sees only this schema.
+ schema = InputSchema.model_validate(
+ {
+ "nodes": {
+ "a": {
+ "host": "h1", "user": "u", "ssh_password": "p",
+ "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}},
+ },
+ "b": {
+ "host": "h2", "user": "u", "ssh_password": "p", "required": False,
+ "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}},
+ },
+ }
+ }
+ )
+ # Output: node "b" failed (required: false), only node "a"'s kube target
+ # actually materialized -- output cardinality (1) SHRANK below input
+ # cardinality (2). This is the real _build_child_env sees post-spawn.
+ out = OutputSchema(
+ connections={
+ "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/run/s/tunnel-data/k3s")}),
+ },
+ pid=1, session_dir="/run/s", started_at="now",
+ warnings=[TunnelWarning(node="b", error="optional node refused the forward")],
+ )
+ actual = _build_child_env(out, output_var=None, input_env=None)
+ # Subset, not equality: predicted (conservative, computed from input
+ # cardinality 2) legitimately claims MORE than actual (exact, computed
+ # from output cardinality 1) -- that asymmetry is the whole point.
+ assert set(actual) <= predicted_env_keys(schema)
+ # Anti-vacuity: KUBE_CONFIG_PATHS specifically must be in the prediction
+ # even though it is NOT in the actual export (the >=2 branch never fires
+ # here) -- this is the exact key an exact-cardinality predictor would
+ # have wrongly omitted.
+ assert "KUBE_CONFIG_PATHS" in predicted_env_keys(schema)
+ assert "KUBE_CONFIG_PATHS" not in actual
+```
+
+This test needs `_build_child_env` (Task 5), so it is added here, in Task 5,
+alongside `_build_child_env`'s own implementation — not in Task 3, where
+`predicted_env_keys`'s formula lands but `_build_child_env` does not yet
+exist. Task 3's own two formula tests (above) are sufficient at that point;
+this safety-envelope test is the piece that specifically needs both sides to
+exist simultaneously.
+
+**Files** (updated for the full blast radius; the earlier draft of this task
+covered only the first three rows):
+- Modify: `tunstrap/cli.py`, `tunstrap/envrender.py` (delete `render_env`,
+ retarget the anti-drift guard's sibling code), `tunstrap/exceptions.py`
+ (delete `MultiNodeEnvUnsupported`)
+- Test (unit): `tests/unit/test_cli_run_output_var.py`,
+ `tests/unit/test_cli_run_output_var_projection.py`,
+ `tests/unit/test_cli_run_materialize.py` (new),
+ `tests/unit/test_cli_run.py`, `tests/unit/test_cli_run_input_env_scrub.py`,
+ `tests/unit/test_cli_runner.py`, `tests/unit/test_cli_run_postspawn.py`,
+ `tests/unit/test_envrender.py`, `tests/unit/test_exceptions.py`,
+ `tests/unit/test_tofu_proxy.py` (docstrings only)
+- Test (integration): `tests/integration/test_run_env_io.py`,
+ `tests/integration/test_cli_modes.py`
+- Test/artifact (e2e, if the e2e tier is exercised — Task 6 owns the actual
+ edits since they land alongside the recipe, but they are enumerated here
+ because they are this task's blast radius, not new scope):
+ `tests/e2e/module/main.tf`, `tests/e2e/rig.py`,
+ `tests/e2e/test_tofu_providers.py`, `tests/e2e/test_terragrunt_apply.py`
+
+- [ ] **Step 1: Write failing tests**
+
+New file `tests/unit/test_cli_run_materialize.py`:
+
+```python
+"""run's unified-output materialization: /tunnel-data/output.json.
+
+Validates: run always writes the unified structure to a deterministic path,
+mode 0600, regardless of --output-var or node count; the file's content
+equals render_unified_output's output for the same OutputSchema.
+Code: tunstrap/cli.py (materialization call site)
+Method: CliRunner + spawn_daemon/Popen/_teardown_run monkeypatched, as in
+test_cli_run_output_var.py; read the file back after invoke().
+"""
+
+from __future__ import annotations
+
+import json
+import stat
+from pathlib import Path
+from typing import Any
+
+import pytest
+from click.testing import CliRunner
+
+from tests.unit.conftest import cleaning_teardown
+from tunstrap import cli as cli_mod
+from tunstrap.cli import main
+from tunstrap.envrender import render_unified_output
+from tunstrap.schemas import OutputSchema
+
+pytestmark = pytest.mark.unit
+
+
+def test_run_materializes_output_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A single-node run writes tunnel-data/output.json, mode 0600, matching content."""
+ session_dir = tmp_path / "session"
+ session_dir.mkdir()
+ payload = {
+ "connections": {"h": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}}},
+ "pid": 99, "session_dir": str(session_dir), "started_at": "2026-08-07T00:00:00Z",
+ }
+ monkeypatch.setattr(
+ cli_mod, "spawn_daemon",
+ lambda schema, session_dir=None, *, input_env=None: {"kind": "success", "payload": payload},
+ )
+ monkeypatch.setattr(cli_mod.subprocess, "Popen", _FakePopen)
+ monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown)
+ monkeypatch.setenv("TUNSTRAP_INPUT", json.dumps({"nodes": {"node": {
+ "host": "h", "user": "u", "ssh_password": "p", "remote_targets": {"db": "127.0.0.1:5432"},
+ }}}))
+ result = CliRunner().invoke(
+ main, ["run", "--input-env", "TUNSTRAP_INPUT", "--", "true"]
+ )
+ assert result.exit_code == 0, result.stderr
+ materialized = session_dir / "tunnel-data" / "output.json"
+ assert materialized.exists()
+ assert stat.S_IMODE(materialized.stat().st_mode) == 0o600
+ out = OutputSchema.model_validate(payload)
+ assert json.loads(materialized.read_text()) == render_unified_output(out)
+ assert _FakePopen.last_env is not None
+ assert _FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == str(materialized)
+
+
+class _FakePopen:
+ last_env: dict[str, str] | None = None
+ returncode = 0
+
+ def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None:
+ _FakePopen.last_env = env
+
+ def wait(self) -> int:
+ return 0
+
+ def send_signal(self, _signum: int) -> None:
+ pass
+```
+
+Add to `tests/unit/test_cli_run_output_var.py`:
+
+```python
+def test_multi_node_run_succeeds_without_output_var(
+ monkeypatch: pytest.MonkeyPatch, spawn: list[Any]
+) -> None:
+ """Multi-node input with NO --output-var now succeeds -- was exit 1
+ MultiNodeEnvUnsupported before this task; materialization covers
+ multi-node unconditionally so the opt-in gate has nothing left to force."""
+ survivor_a = {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": _RICH_KUBE}}
+ other_kube = dict(_RICH_KUBE, path="/s/tunnel-data/node-b-k3s")
+ survivor_b = {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": other_kube}}
+ spawn[0](
+ {
+ "kind": "success",
+ "payload": {
+ "connections": {"a": survivor_a, "b": survivor_b},
+ "pid": 99, "session_dir": "/s", "started_at": "2026-08-07T00:00:00Z",
+ },
+ }
+ )
+ monkeypatch.setenv(VAR, _payload({"a": _node(), "b": _node()}))
+ result = CliRunner().invoke(main, ["run", "--input-env", VAR, "--", "true"])
+ assert result.exit_code == 0, result.stderr
+ assert FakePopen.last_env is not None
+ joined = "/s/tunnel-data/node-k3s:/s/tunnel-data/node-b-k3s"
+ assert FakePopen.last_env["KUBECONFIG"] == joined
+ assert FakePopen.last_env["KUBE_CONFIG_PATHS"] == joined
+ assert "KUBE_CONFIG_PATH" not in FakePopen.last_env
+ assert FakePopen.last_env["TUNSTRAP_SESSION_DIR"] == "/s"
+ assert FakePopen.last_env["TUNSTRAP_PID"] == "99"
+ assert FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == "/s/tunnel-data/output.json"
+
+
+def test_suppress_kubeconfig_drops_all_three_kube_env_names(
+ monkeypatch: pytest.MonkeyPatch, spawn: list[Any]
+) -> None:
+ """suppress_kubeconfig (the tunstrap_tofu proxy's guard) must drop
+ KUBE_CONFIG_PATH/_PATHS too, not just KUBECONFIG -- see decision history
+ #7: those are the names the providers actually read."""
+ from tunstrap.cli import _build_child_env
+ from tunstrap.schemas import OutputSchema
+
+ out = OutputSchema.model_validate(
+ {
+ "connections": {"h": {"ports": {}, "kube_targets": {"k3s": _RICH_KUBE}}},
+ "pid": 1, "session_dir": "/s", "started_at": "now",
+ }
+ )
+ env = _build_child_env(out, output_var=None, input_env=None, suppress_kubeconfig=True)
+ assert "KUBECONFIG" not in env
+ assert "KUBE_CONFIG_PATH" not in env
+ assert "KUBE_CONFIG_PATHS" not in env
+```
+
+- [ ] **Step 2: Retarget every pre-existing test pinning the removed machinery**
+
+**`tests/unit/test_cli_run_output_var.py`** — this file's whole premise (the
+scalar/`--output-var` interaction) partly no longer exists. Retarget or
+delete:
+
+- `test_collision_with_injected_scalar_is_usage_error` — pins
+ `--output-var TUNSTRAP_DB_PORT` colliding with an injected scalar.
+ `TUNSTRAP_DB_PORT` is no longer ever injected (no scalars), so this
+ collision can no longer occur. **Delete this test**, not retarget — there
+ is no equivalent behaviour to assert once the collision class it tested is
+ gone.
+- `test_non_colliding_tunstrap_prefixed_name_is_accepted` — asserts a
+ `TUNSTRAP_`-prefixed `--output-var` NAME is accepted because only *some*
+ `TUNSTRAP_` keys are protected. Retarget: rename to
+ `test_tunstrap_prefixed_output_var_name_is_accepted` and drop the "only
+ some are protected" framing from the docstring — under the new contract no
+ `TUNSTRAP__*` key exists to be protected from at all; the test's
+ only remaining job is confirming `--output-var TUNSTRAP_ANYTHING` is not
+ specially rejected just for the prefix.
+- `test_multi_node_without_output_var_is_exit_1_pre_spawn` — pins the exact
+ behaviour Step 1's new `test_multi_node_run_succeeds_without_output_var`
+ inverts. **Delete this test**; it is superseded by the new one, not
+ retargetable (the assertion is the literal opposite).
+- `test_multi_node_with_output_var_reaches_spawn` — still valid in spirit
+ (multi-node + `--output-var` reaches `spawn_daemon`) but its docstring
+ ("until it lands render_env would still reject a two-node envelope
+ post-spawn") describes removed code. Update the docstring only; the
+ assertions are unaffected (it never inspects env content, only that
+ `spawn_daemon` was reached).
+- `test_output_var_carries_the_whole_envelope_minus_kube_credentials` — pins
+ the **old** shape (`connections..ports.` as an int,
+ `RunKubeTarget`'s exact field set). **Retarget in place**: rename to
+ `test_output_var_carries_the_unified_structure_minus_kube_credentials`,
+ replace `_RICH_PAYLOAD`'s expected-shape assertions with the unified
+ shape's (`nodes.node.ports.db == "127.0.0.1:5432"`,
+ `nodes.node.kube.k3s == {"path": ..., "context": ..., "endpoint": ...}`,
+ `nodes.node.fetch_files.hosts.sha256 == ...`), keep the credential-absence
+ assertions (`client_certificate_data`/`client_key_data`/`content_b64` still
+ must not appear anywhere in the decoded payload) — that property is
+ unchanged, only the container shape is.
+- `test_single_node_keeps_scalars_alongside_output_var` — pins
+ `TUNSTRAP_DB_PORT`/`TUNSTRAP_DB_ENDPOINT` in the child env. **Delete**; no
+ scalars survive to keep "alongside" anything except
+ `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`, already covered by
+ `test_child_env_without_output_var_is_unchanged` below.
+- `test_multi_node_injects_output_var_and_no_scalars` — retarget: the
+ "no scalars" half is now trivially true (nothing produces them), so the
+ test's remaining job is confirming the unified structure carries both
+ nodes correctly; update its body to decode `render_unified_output`'s shape
+ (`nodes` keyed by `"a"`/`"b"`) instead of the old `OutputSchema.connections`
+ shape, keep the `leaked` scalar-absence assertion (still a real guard
+ against a regression that reintroduces target-scoped scalars).
+- `test_multi_node_suppression_uses_input_count` (**already retargeted once**,
+ in iteration 2, to `test_multi_node_suppresses_scalars_but_exports_kube_channel`)
+ — retarget **again**: its `leaked = [...TUNSTRAP_...]; assert leaked == []`
+ assertion no longer describes a real guard (there is no
+ `inject_scalars`/input-node-count decision left to get wrong — the kube
+ channel is unconditional by construction after this task, so there is
+ nothing left to falsify). Rename to
+ `test_optional_node_failure_does_not_affect_kube_channel_or_unified_output`
+ and rewrite the body to assert: the kube channel still fires for the one
+ surviving connection (`KUBECONFIG`/`KUBE_CONFIG_PATH` present), and the
+ unified structure (if `--output-var` given) reflects only the surviving
+ node (`"b"` absent from `nodes`, its failure visible in
+ `session.warnings`). Drop the `leaked` assertion entirely — nothing
+ produces `TUNSTRAP_`-prefixed target scalars anymore, so asserting their
+ absence is now asserting a tautology, not a guard.
+- `test_child_env_without_output_var_is_unchanged` — retarget: the expected
+ `injected` dict shrinks to exactly `{"TUNSTRAP_SESSION_DIR": "/s",
+ "TUNSTRAP_PID": "99", "TUNSTRAP_OUTPUT_FILE": "/s/tunnel-data/output.json"}`
+ (drop `TUNSTRAP_DB_HOST`/`_PORT`/`_ENDPOINT` from the expected dict; this
+ node has no kube_targets in its fixture, so no kube keys are expected
+ either). Docstring updated to say "the three survivors, session metadata
+ only." Widen the `injected` filter (`k.startswith(("TUNSTRAP_",
+ "KUBECONFIG"))`) is already broad enough to catch `TUNSTRAP_OUTPUT_FILE`
+ automatically — no filter change needed, only the expected dict.
+
+**`tests/unit/test_envrender.py`** — delete every `render_env`-specific test
+**by name** (the blast-radius table above lists these; restated here as the
+concrete instruction): `test_render_ports_and_session`,
+`test_render_kube_sets_kubeconfig`, `test_render_kube_not_materialized_raises`,
+`test_render_requires_single_node_zero`, `test_render_requires_single_node_two`,
+and **`test_render_kube_env_works_for_multi_node_while_render_env_still_rejects`**
+(added by Task 3 itself — do not miss this one, it was undercounted in an
+earlier revision of this plan). Also delete the now-unused module-level
+`from tunstrap.exceptions import MultiNodeEnvUnsupported` import
+(`test_envrender.py:4` — `ruff` F401 once nothing in the file uses it) and
+`test_predicted_env_keys_no_kube_omits_kubeconfig`,
+`test_predicted_env_keys_multi_node_is_empty` (both pin the old per-target
+enumeration / the old "multi-node predicts nothing" claim, superseded below).
+
+**Do NOT delete `test_predicted_env_keys_matches_render_env`.** An earlier
+revision of this plan deleted it on the false premise that only one
+implementation of the injected-key set remained after this task — false, see
+"Anti-drift guard — retargeted, not deleted, and now two-part (R11)" above,
+which is the actual, correct disposition and supersedes this paragraph if the
+two ever disagree. **[R11]** Retarget it in place to
+`test_predicted_env_keys_covers_actual_injected_keys_under_cardinality_
+shrink` (the safety-envelope half; code given in full above) — not to a
+single full-equality test named `..._matches_actual_injected_keys`, which was
+this same paragraph's own iteration-4/5 name and is stale now that
+`predicted_env_keys` is conservative rather than exact (see R11). Add it
+here, in `test_envrender.py`, not as a new file.
+
+Replace the two deleted "shape" tests with:
+
+```python
+def test_predicted_env_keys_is_session_scalars_plus_kube_channel() -> None:
+ """[R11] predicted_env_keys collapses to the three survivors + the
+ CONSERVATIVE kube channel (all three names, not just the >=2 branch that
+ this input's exact declared cardinality would exactly hit) -- there is no
+ other injected key left, and the formula does not vary by exact count."""
+ schema = InputSchema.model_validate(
+ {
+ "nodes": {
+ "a": {
+ "host": "h", "user": "u", "ssh_password": "p",
+ "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}},
+ },
+ "b": {
+ "host": "h2", "user": "u", "ssh_password": "p",
+ "kube_targets": {"k4s": {"kubeconfig_path": "/etc/k4s.yaml"}},
+ },
+ }
+ }
+ )
+ assert predicted_env_keys(schema) == {
+ "TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE",
+ "KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS",
+ }
+
+
+def test_predicted_env_keys_no_kube_is_just_the_three_survivors() -> None:
+ schema = InputSchema.model_validate(
+ {"nodes": {"a": {"host": "h", "user": "u", "ssh_password": "p",
+ "remote_targets": {"db": "127.0.0.1:1"}}}}
+ )
+ assert predicted_env_keys(schema) == {
+ "TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE",
+ }
+```
+
+`format_exports`'s own test (`test_format_exports_quotes_safely`) is
+unaffected — it takes a plain `dict[str, str]`, not an `OutputSchema`.
+
+**`tests/unit/test_exceptions.py`**: delete **all three** `MultiNodeEnvUnsupported`
+cases by name, not "whatever case pins the exit code" (an earlier revision of
+this plan undercounted these twice): the subclass check (`:87-90`,
+`issubclass(MultiNodeEnvUnsupported, TunstrapError)`), the exit-code +
+envelope test (`:94-99`, constructs an instance and checks
+`to_error_output()["error"]`), and the `_EXIT_CODES` table test (`:107-114`,
+`_EXIT_CODES[MultiNodeEnvUnsupported] == 1`).
+
+**`tests/unit/test_tofu_proxy.py`**: the two `suppress_kubeconfig`-related
+tests (`test_tunnelled_suppresses_kubeconfig_in_child_env`,
+`test_tunnelled_drops_an_inherited_kubeconfig_in_the_multi_node_case`) keep
+their assertions unchanged (still correct: `KUBECONFIG` still must not leak)
+but their fixtures currently rely on single-node-vs-multi-node framing in
+their docstrings ("For single-node the post-injection pop already removes
+KUBECONFIG... For multi-node... inject_scalars=False") — update both
+docstrings to drop the `inject_scalars` framing entirely (there is no
+branch left to describe; one unconditional pop covers every case, as
+iteration 2's side note already anticipated).
+
+- [ ] **Step 3: Run to verify failure**
+
+`.venv/bin/pytest tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py tests/unit/test_exceptions.py -v`
+Expected: FAIL across the board — `render_env`/`MultiNodeEnvUnsupported`/
+`inject_scalars` still exist and behave the old way; `_build_child_env` still
+requires `inject_scalars` and injects only two survivors, not three; no
+materialization call site exists yet; `start --output env` still emits
+per-target scalars.
+
+- [ ] **Step 4: Implement**
+
+`tunstrap/exceptions.py`: delete the `MultiNodeEnvUnsupported` class and its
+`_EXIT_CODES` entry.
+
+`tunstrap/envrender.py`: delete `render_env` in its entirety, and the
+now-unused `from tunstrap.exceptions import MultiNodeEnvUnsupported` import
+(`envrender.py:13`, mirroring the same fix in `test_envrender.py:4`). Rewrite
+`predicted_env_keys`:
+
+```python
+def predicted_env_keys(schema: InputSchema) -> set[str]:
+ """Env keys ``run`` will inject for this *input* schema, unconditional on
+ node count: the three session scalars, plus -- [R11] conservatively, not
+ per the exact _kube_channel_keys(count) branch -- all three kube names
+ whenever any node declares kube_targets at all. Input cardinality can
+ shrink by output time (an optional node/target can fail without failing
+ the run), so predicting the exact branch would under-reserve; see the
+ "Anti-drift guard" section for the cardinality-shrink case this guards
+ against. Used pre-spawn to reject a colliding --output-var NAME before a
+ daemon exists.
+ """
+ keys = {"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE"}
+ if any(node.kube_targets for node in schema.nodes.values()):
+ keys |= {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"}
+ return keys
+```
+
+This is the same conservative rule Task 3 already gave `predicted_env_keys`
+(above, in Task 3 Step 4) — Task 5 does not re-derive it, it only drops the
+scalar half's `if len(schema.nodes) == 1:` per-target block per that task's
+own "Note, still valid" callout. An earlier draft of this Task 5 rewrite
+re-introduced `_kube_channel_keys(total_kube)` (the exact per-count branch)
+here by mistake, which would have silently reverted the R11 fix for every
+caller that hits this later body instead of Task 3's; fixed in place.
+
+`tunstrap/session.py`: confirm `SessionDir._write_file`'s exact signature
+(`session.py:132` per the design doc's citation) before Step 4 item 4 below.
+**[R13] It is not a drop-in reuse** — `_write_file` is mode-fixed-at-creation
+but not atomic (`O_TRUNC`, no rename step), while materialization needs true
+atomicity too (temp file + `os.replace`); check whether `_write_file` can be
+refactored into a shared atomic-replace helper both call sites use, or
+whether the primitive is replicated inline in `cli.py` instead — see item 4.
+
+`tunstrap/cli.py`:
+
+1. **Remove the `inject_scalars` parameter from all three places that thread
+ it, named explicitly (an earlier revision of this plan hedged with
+ "whichever of these two names is correct" — both exist, and a third does
+ too):**
+ - `_build_child_env` (`cli.py:365-372`, the parameter declaration) —
+ remove the parameter and its `if inject_scalars:` branch (`cli.py:399`).
+ - `_run_child` (`cli.py:466-474`, parameter; `cli.py:486`, passed through
+ to `_build_child_env`).
+ - `_supervise_child` (`cli.py:513-521`, parameter; `cli.py:543`, passed
+ through to `_run_child`).
+ - `run_command` (`cli.py:648`, `inject_scalars = len(schema.nodes) == 1`
+ — delete the line entirely; `cli.py:702`, the keyword argument passed
+ to `_supervise_child` — delete it from the call).
+ Confirm all four sites against the checked-out file rather than trusting
+ these line numbers verbatim — they are a reading of the pre-iteration-4
+ tree and may have shifted by the time Tasks 1-4 land ahead of this one.
+2. Remove the `cli.py:640` pre-spawn block:
+ ```python
+ if output_var is None and len(schema.nodes) != 1:
+ raise MultiNodeEnvUnsupported(...)
+ ```
+ entirely — multi-node without `--output-var` is no longer rejected.
+3. Rewrite `_build_child_env`:
+
+```python
+def _build_child_env(
+ output: OutputSchema,
+ *,
+ output_var: str | None,
+ input_env: str | None,
+ suppress_kubeconfig: bool = False,
+) -> dict[str, str]:
+ child_env = dict(os.environ)
+ if input_env is not None:
+ child_env.pop(input_env, None)
+ child_env["TUNSTRAP_SESSION_DIR"] = output.session_dir
+ child_env["TUNSTRAP_PID"] = str(output.pid)
+ child_env["TUNSTRAP_OUTPUT_FILE"] = _materialized_output_path(output.session_dir)
+ child_env.update(render_kube_env(output))
+ if suppress_kubeconfig:
+ for key in ("KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"):
+ child_env.pop(key, None)
+ if output_var is not None:
+ child_env[output_var] = render_output_var(output)
+ return child_env
+
+
+def _materialized_output_path(session_dir: str) -> str:
+ """The deterministic path materialize_output writes to; shared so
+ _build_child_env's TUNSTRAP_OUTPUT_FILE and the actual writer never
+ independently compute a different path for the same file."""
+ return str(Path(session_dir) / "tunnel-data" / "output.json")
+```
+
+ No branch, no `inject_scalars` parameter anywhere in the call chain.
+4. **[R13] Materialization writer — true atomic replace, not
+ write-then-chmod, and not `O_TRUNC` alone.** `SessionDir._write_file`'s
+ real property is **mode-fixed-at-creation** (`session.py:132`,
+ `os.open(path, O_CREAT | O_WRONLY | O_TRUNC, 0o600)`, no separate
+ `chmod`) — **not** "atomic" in the sense that matters here: `O_TRUNC`
+ overwrites the file *in place*, visible mid-write to a concurrent reader.
+ `Path.write_text()` + `.chmod(0o600)` is even worse (a real,
+ umask-dependent `0644` window before `chmod` closes it). Neither is
+ sufficient on its own: this write needs *both* mode-fixed-at-creation
+ *and* true atomicity. **[R16, iteration 8 — rationale re-grounded, not
+ just retargeted.]** An earlier revision justified the atomicity
+ requirement by a `file()` call in a consumer's HCL racing a `run` restart
+ rewriting the *same pinned path* — that race is retired under R16 (design
+ doc, "Delivery," mode 2 is gone; `TUNSTRAP_OUTPUT_FILE` names a fresh
+ per-invocation path, written before the child spawns, so nothing reads it
+ concurrently with this write today). The requirement **stays** — strictly
+ safer than `O_TRUNC`, costs nothing — on grounds that do not depend on
+ that retired race: (1) torn-read prevention if this process is killed
+ mid-write (a truncated file at the final path is indistinguishable from a
+ valid short one to a naive reader; `os.replace` guarantees only a complete
+ old or complete new file is ever observable); (2) defense-in-depth against
+ any future change that reintroduces a stable/reusable path; (3) the
+ fetched-file materialization writer (Task 5's "Fetched-file
+ materialization" subsection, below) shares this exact primitive, so one
+ atomic-replace helper is reasoned about once, not twice. Use a temp file +
+ rename:
+
+```python
+ materialized_path = _materialized_output_path(output.session_dir)
+ tunnel_data_dir = Path(materialized_path).parent
+ tunnel_data_dir.mkdir(parents=True, exist_ok=True)
+ tmp_path = tunnel_data_dir / f".output.json.{os.getpid()}.tmp"
+ fd = os.open(tmp_path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600)
+ try:
+ os.write(fd, render_output_var(output).encode())
+ finally:
+ os.close(fd)
+ os.replace(tmp_path, materialized_path)
+```
+
+ `O_EXCL` on the temp file guards against a colliding temp name (the mode
+ is already fixed at creation, same as the existing primitive); `os.replace`
+ is the atomic step — a single filesystem rename, so a reader can never
+ observe a partial write. If `SessionDir._write_file` can be refactored
+ into something callable without a live `SessionDir` instance (this
+ writer runs in the CLI **parent** process, `run_command`, which holds no
+ `SessionDir` — kube materialization happens daemon/worker-side, inside
+ the process that does own one), factor the atomic-replace primitive
+ above into a small shared helper in `session.py` both call sites use;
+ otherwise replicate it in `cli.py` as shown — do not describe this as
+ "reusing `_write_file`" if the code is not actually shared, since the
+ temp-file + `os.replace` step is new work `_write_file` does not
+ currently do at all.
+
+ Placed in `run_command`'s success path — the same place `_build_child_env`
+ is already called, inside the `try` that owns teardown (design spec
+ `2026-07-31-run-env-io-and-tofu-proxy-design.md`'s "Cleanup must own the
+ whole post-spawn window" invariant applies here too: writing this file is
+ new work in that same protected window, so it must go inside the existing
+ `try`, not before it) — unconditionally (regardless of `--output-var`,
+ regardless of node count). **Confirm the exact `run_command` call site
+ against the checked-out `cli.py`** — the pre-#15 design's line numbers for
+ this function have already drifted once (`cli.py:302-308` in that design's
+ own text vs. later citations in this plan at `cli.py:640`/`cli.py:648`),
+ so re-resolve by reading the function, not by trusting a stale citation.
+5. **`start_command`'s `--output env` mode** (`cli.py:204-206`,
+ `sys.stdout.write(format_exports(render_env(out)))`) is the **other**
+ caller of `render_env` — deleting the function without touching this call
+ site breaks `start` outright (`NameError`), not just a stale test. `start`
+ also now materializes under `--output env` (only) — mirroring `run`'s
+ conditional materialization: it already
+ forces `daemon.materialize` under `--output env`, per `cli.py:191`'s
+ `force_materialize=(output_fmt == "env")`, so the kube files already land
+ on disk; extend that to also write `output.json` via the same
+ `_materialized_output_path`/secure-write helper from item 4). Update the
+ `--output env` branch to build the same three-survivors-plus-kube-channel
+ mapping `_build_child_env` now uses:
+ ```python
+ if kind == "success" and output_fmt == "env":
+ out = OutputSchema.model_validate(message["payload"])
+ _write_materialized_output(out) # same helper as item 4
+ env = {
+ "TUNSTRAP_SESSION_DIR": out.session_dir,
+ "TUNSTRAP_PID": str(out.pid),
+ "TUNSTRAP_OUTPUT_FILE": _materialized_output_path(out.session_dir),
+ }
+ env.update(render_kube_env(out))
+ sys.stdout.write(format_exports(env))
+ ```
+ Fix the existing test pinning this mode (`test_cli_runner.py:392`, see the
+ blast-radius table) in place — do not just add a new test alongside a
+ stale one.
+
+ **[R13] Stdin-mode guard — a real reachable failure, not a theoretical
+ one.** `--output env` forces `daemon.materialize = True` only for **flag
+ mode** (`build_flag_schema`'s `force_materialize=(output_fmt == "env")`,
+ `cli.py:191`); a **stdin**-supplied payload's own `daemon.materialize` is
+ the caller's explicit statement and `_pick_start_input_schema` leaves it
+ alone (`cli.py:160-174`, docstring: *"a stdin payload's daemon.materialize
+ is the caller's own statement and is left alone"*). A stdin payload that
+ declares `kube_targets` with `materialize: false` under `--output env`
+ therefore reaches the now-unconditional `render_kube_env(out)` call with
+ `target.path is None` for that target, which raises a bare `ValueError` —
+ an ugly traceback, not a typed error. **Fix, before wiring the unconditional
+ call above:** either (a) force `daemon.materialize = True` for the stdin
+ path too when `output_fmt == "env"`, matching flag mode's own precedent
+ (simplest, and consistent — `--output env` needs materialized kube paths
+ regardless of input channel), or (b) catch `ValueError` around the
+ `render_kube_env` call in this branch and re-raise as a typed
+ `TunstrapError` subclass with a clear message. **Choose (a)** unless a
+ reviewer specifically wants materialization to stay an operator opt-out
+ even under `--output env` — it is the smaller change and matches the
+ existing flag-mode precedent exactly. Add a unit test: stdin payload,
+ `daemon.materialize: false`, `kube_targets` declared, `--output env` →
+ either exit 0 with the kube path materialized (if (a)), or a typed error
+ (if (b)) — never a bare `ValueError` traceback.
+
+- [ ] **Step 5: Run to verify pass, then the full suite**
+
+`.venv/bin/pytest tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py tests/unit/test_exceptions.py tests/unit/test_tofu_proxy.py -v`
+Expected: **all pass, and only after every row of the blast-radius table
+above has actually been applied** — this is not "full pass" as a hope, it is
+"full pass" as the definition of this step being done; a partial pass with a
+handful of still-red tests means a table row was skipped, not that the row
+was optional.
+
+`.venv/bin/pytest tests/unit -q`
+Expected: full pass. Read the actual count; do not compare against any
+number recorded in this plan or the spike findings — both predate this
+task's deletions and retargets.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add tunstrap/cli.py tunstrap/envrender.py tunstrap/exceptions.py tunstrap/session.py \
+ tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py \
+ tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py \
+ tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py \
+ tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py \
+ tests/unit/test_exceptions.py tests/unit/test_tofu_proxy.py
+git commit -m "feat: unified output materialization; remove TUNSTRAP_* scalars, MultiNodeEnvUnsupported, inject_scalars (#15)"
+```
+
+**Note:** `tunstrap/session.py` is only in this commit if Step 4 item 4 added
+a small shared secure-write helper there; omit it if `SessionDir._write_file`
+was reusable as-is.
+
+- [ ] **Step 7: Integration retargets**
+
+The blast-radius table's integration rows are their own step, not folded
+into Task 7's gate pass — they are behavioural retargets (TDD-shaped: they
+can fail against the old code and must pass against the new), not a
+verification-only pass.
+
+**Files:**
+- Test: `tests/integration/test_run_env_io.py`, `tests/integration/test_cli_modes.py`
+
+Apply every integration row from the blast-radius table above:
+`_PROBE_SINGLE`/`_PROBE_MULTI` read `TUNSTRAP_OUTPUT_FILE` instead of
+`TUNSTRAP_WEB_PORT`; the "no scalar leak" check excludes the three sanctioned
+survivors; `test_multi_node_without_output_var_is_exit_1` is renamed and
+inverted to `test_multi_node_without_output_var_now_succeeds`;
+`test_cli_modes.py`'s `start --output env` test and `run`'s child probe both
+retarget to `TUNSTRAP_OUTPUT_FILE`. Run (requires Docker, per
+`tests/README.md`):
+
+```bash
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/integration -m integration -q -k "run_env_io or cli_modes"
+```
+
+Expected: FAIL before the retargets (old assertions against new behaviour),
+PASS after. Commit:
+
+```bash
+git add tests/integration/test_run_env_io.py tests/integration/test_cli_modes.py
+git commit -m "test(integration): retarget env-shape assertions for the unified output contract (#15)"
+```
+
+---
+
+### Task 6: Recipe documentation + e2e artifact shape migration (work item 4, extended by the pivot)
+
+**This task also carries the e2e-tier and shipped-recipe rows of Task 5's
+blast-radius table** — they land here, not in Task 5, because they are the
+same textual shape migration as the new recipe content this task writes, and
+`test_recipe_terragrunt.py`'s drift guard requires the recipe and
+`tests/e2e/module/main.tf` to move together or it fails by design.
+
+**Files:**
+- Modify: `docs/recipe_terragrunt.md`, `tests/e2e/module/main.tf`,
+ `tests/e2e/rig.py`, `tests/e2e/test_tofu_providers.py`,
+ `tests/e2e/test_terragrunt_apply.py`
+
+- [ ] **Step 0: Fix the recipe's pre-existing `connections.*` shape (before adding new content)**
+
+The recipe already contains working HCL/prose in the old shape, predating
+this pivot — fix these **in place** before Step 1/2 add anything new, so the
+document is never left in a self-contradictory state (old shape in one
+section, new shape in another):
+
+- `docs/recipe_terragrunt.md:287-288` — the `tunnel`/`kubepath` locals:
+ `try(jsondecode(var.tunstrap), { connections = {} })` →
+ `try(jsondecode(var.tunstrap), { nodes = {} })`;
+ `local.tunnel.connections.node.kube_targets.k3s.path` →
+ `local.tunnel.nodes.node.kube.k3s.path`.
+- `docs/recipe_terragrunt.md:~329` — prose point 3, "`path` comes from the
+ materialized file... `connections.*.kube_targets.*.path`" → retarget to
+ `nodes.*.kube.*.path`.
+- `docs/recipe_terragrunt.md:~407` — "The input variable is scrubbed"
+ section: "the module picks the node out of `connections[]`" →
+ `nodes[]`; also correct the surrounding paragraph's claim that
+ multi-node input suppresses the scalar/`KUBECONFIG` channel entirely — that
+ was true pre-pivot and is false now (the kube channel is unconditional;
+ only the `TUNSTRAP__*` scalars, which no longer exist as a concept,
+ were ever suppressed for multi-node).
+- `docs/recipe_terragrunt.md:~509` — "What is proven" section: verify only,
+ no shape-specific text to change (the `--output-var` → `TF_VAR_tunstrap` →
+ `jsondecode` → `config_path` chain description stays accurate once the two
+ locals above change).
+
+**`tests/e2e/module/main.tf:27-28`** — the exact chain the e2e tier proves,
+mirroring the recipe: `try(jsondecode(var.tunstrap), { connections = {} })`
+→ `{ nodes = {} }`; `local.tunnel.connections.node.kube_targets.k3s.path` →
+`local.tunnel.nodes.node.kube.k3s.path`. Update the module's own header
+comment (`main.tf:1-9`, "The exact chain this tier exists to prove") to match.
+
+**`tests/e2e/rig.py:171`** — docstring: "`module/main.tf` decodes
+`connections.node.kube_targets.k3s.path`" → retarget to `nodes.node.kube.
+k3s.path`.
+
+**`tests/e2e/test_tofu_providers.py:154`** —
+`envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` →
+`envelope["nodes"]["node"]["kube"]["k3s"]["path"]`.
+
+**`tests/e2e/test_tofu_providers.py:251-254`** — the fake envelope dict
+literal (`"connections": {"node": {"ports": {}, "kube_targets": {"k3s":
+{...}}}}}`) → retarget to `{"nodes": {"node": {"ports": {}, "kube": {"k3s":
+{"path": ..., "context": ..., "endpoint": ...}}}}}`, aligning the literal's
+field names with `UnifiedKubeRef` (drop any field beyond `path`/`context`/
+`endpoint` the old literal happened to carry — this fixture only needs
+enough to exercise the dead-cluster negative-control scenario it drives).
+
+**`tests/e2e/test_terragrunt_apply.py:339,425`** —
+`envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` (apply and
+tunnelled-output cases) → `envelope["nodes"]["node"]["kube"]["k3s"]["path"]`
+at both sites.
+
+**Not touched, disposition recorded (from Task 5's table, restated for
+completeness at the point where a reader would otherwise expect to find
+them fixed):** `tests/e2e/test_rig.py:278` reads `start`'s raw stdout JSON,
+which is out of the pivot's scope (design doc judgment call); `tests/e2e/
+test_recipe_terragrunt.py`'s drift guard needs no code change — its compared
+content updates automatically once the steps above land.
+
+- [ ] **Step 0b: [R16, iteration 8, new] Rewrite `docs/recipe_terragrunt.md:366-388`
+ — the "Fetched files are exported verbatim, not projected" subsection**
+
+Found by the widened `content_b64` grep (Task 5's enumeration, "docs tier"
+rows) — this shipped subsection currently argues the **opposite** of R16's
+shipped behaviour (fetch content stays whole in the envelope, `FetchedFile`
+has no `path`, dropping `content_b64` would be "a silent, unrecoverable
+breakage"). Fix in place, same "before Step 1/2 add anything new" discipline
+as Step 0 above — this is pre-existing content, not new content Step 2 adds.
+Replace the entire subsection (heading through the final paragraph ending
+"...is recorded in the spec's 'Out of scope'.") with:
+
+> ### Fetched files are materialized, not carried in the envelope
+>
+> The projection above (kube) and this one (`fetch_files`) now follow the
+> same rule: `run` materializes content to disk under the session dir's
+> `tunnel-data/`, mode `0600`, and the consumer-facing envelope carries only
+> a reference to it. Each `fetch_files` entry becomes `{path, size, sha256}`
+> on success, `{error}` on failure — never `content_b64`.
+>
+> This supersedes the asymmetry an earlier revision of this document
+> described: `FetchedFile` **now has a `path`** (`schemas.py`, extended for
+> this ticket), so the "dropping `content_b64` would be a silent,
+> unrecoverable breakage" premise that justified keeping content in the
+> envelope no longer holds — the lossless on-disk alternative that argument
+> said was missing now exists, the same way it already existed for kube.
+>
+> **The plan-file-persistence risk this asymmetry existed to warn about is
+> resolved as a class, not documented around**: since fetched content never
+> enters `TF_VAR_tunstrap` or the materialized file at all, `--fetch`ing a
+> secret no longer risks it landing in a saved Terraform plan file through
+> this channel. Read the file directly at `fetch_files..path` if you
+> need its contents.
+
+Also update the one adjacent sentence this rewrite does not itself replace:
+the "One other free-form string rides this channel unprojected:
+`warnings[*].error`" paragraph immediately after (line ~390) stays accurate
+as written — `warnings[*].error` is unrelated to `fetch_files` — but confirm
+after the edit that "One other" still reads correctly given the preceding
+subsection no longer describes `fetch_files` as unprojected at all (the
+prior subsection's own "unprojected" framing is what "other" was
+contrasting against); reword the transition if it no longer parses, do not
+leave a dangling "other."
+
+Verify lines 344 and 361-364 (the kube-drop list and the `start` carve-out,
+immediately above this subsection) need **no** edit — both already state
+kube-specific `content_b64` facts unaffected by R16, confirmed in Task 5's
+enumeration table.
+
+Run (requires `kind`/`tofu`/`kubectl`/Docker, per `tests/README.md` —
+optional locally, mandatory before merge per Task 7 Step 4):
+
+```bash
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e -m e2e -q
+```
+
+Commit this shape-migration half separately from the new recipe content
+below, so a reviewer can see "shape rename, no behaviour change" and "new
+recipe content" as two distinct, independently reviewable diffs:
+
+```bash
+git add tests/e2e/module/main.tf tests/e2e/rig.py tests/e2e/test_tofu_providers.py \
+ tests/e2e/test_terragrunt_apply.py
+git commit -m "test(e2e): retarget to the unified output nodes.*.kube.*.path shape (#15)"
+```
+
+- [ ] **Step 1: Add Mode A — env-native kube [R12, rewritten from "kube-only recipe"]**
+
+Add a new section (placement: after the existing provider-config example, so
+it reads as "and here is the identity-delivery contract that example
+depends on") titled around **Mode A: env-native kube (satisfies the
+ticket's strict "nothing live enters Terraform" contract)**, per the design
+doc's "Documentation" section, Mode A:
+
+1. `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from tunstrap's own process
+ environment (no `var.`-bound value, no file read in HCL at all for kube)
+ **plus a literal `config_context = "tunstrap--"` per
+ provider alias** — a **two-alias worked HCL example**, citing findings #3
+ and #5 by number:
+ ```hcl
+ provider "kubernetes" {
+ alias = "node1_k3s"
+ config_context = "tunstrap-node1-k3s" # literal -- never derived from var.tunstrap
+ }
+ provider "kubernetes" {
+ alias = "node2_k3s"
+ config_context = "tunstrap-node2-k3s"
+ }
+ ```
+2. Explicit warning: never derive `config_context`'s value from
+ `var.tunstrap` or any decoded data — literal only, matching the
+ deterministic naming scheme exactly.
+3. A short "measured facts a consumer needs" list, restated (not
+ re-derived) from **all six** of the ticket's own findings and this
+ design's own provider findings — an earlier revision of this list, despite
+ its own header claiming all six, cited only four; do not repeat that
+ miscount:
+ - **#1** — provider configuration **is** re-evaluated at apply.
+ - **#2** — outputs **freeze silently** — the worst failure mode, name it
+ as such.
+ - **#3** — per-alias `config_context` works with an env-supplied
+ kubeconfig path (Mode A's own basis, shown in item 1's example).
+ - **#4** — plan-safe end to end, measured live: plan with one set of
+ ports, mutate only the kubeconfig, apply the *saved* plan → the alias
+ uses the mutated value, zero plan-variable mismatch — the e2e-level
+ confirmation Mode A's env-native path really is plan-safe.
+ - **#5** — `KUBE_CONFIG_PATHS` is colon-separated (comma silently falls
+ back to `localhost:80`).
+ - **#6** — a live value bound to a `var.` **does** trip "Mismatch between
+ input and plan variable value" on a saved plan (Mode B's one-shot rule,
+ below, rests on this).
+ - A live value bound to a **resource attribute** (not a provider config
+ block) produces `Error: Provider produced inconsistent final plan` —
+ cite the committed provider-precedence spec's Q3
+ result, and show the provider-block placement as the only supported
+ shape in both Mode A and Mode B.
+4. A one-line pointer to the deterministic naming scheme
+ (`tunstrap--`) and why it matters for anyone piping the
+ materialized kubeconfig into `kubectl --context` directly instead of
+ through a provider.
+
+- [ ] **Step 2: Add Mode B — unified-file convenience [R16, iteration 7 — rewritten again, R12's version retracted]**
+
+Immediately after Step 1's section (same document — a real consumer may use
+Mode A for kube and Mode B for ports in the same module), add a section
+titled around **Mode B: unified-file convenience (ports + kube references;
+does NOT satisfy the ticket's strict contract — state this plainly)**, per
+the design doc's "Documentation" section, Mode B (iteration 7 text). **[R16]
+No literal, operator-pinned path and no `var.tunstrap_session_dir`/
+variable-derived locator anywhere in this section** — two earlier revisions
+of this recipe each used one of those two unsound patterns in turn (a
+locator built from `var.tunstrap`; then a literal pinned `--session-dir`
+path); both are retracted, not adapted, per decision history entry 19 (which
+itself supersedes entry 14's pinned-path decision):
+
+5. **The shape**, with a worked HCL example using the env-carried
+ `TUNSTRAP_OUTPUT_FILE` locator via Terragrunt's `get_env(...)` — no
+ `--session-dir` precondition, no operator-agreed path, because the
+ session dir stays ephemeral unconditionally (design doc, "Session dir:
+ ephemeral, but not optional"):
+ ```hcl
+ locals {
+ tunnel = try(
+ jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))),
+ { nodes = {} },
+ )
+ }
+
+ provider "kubernetes" {
+ config_path = local.tunnel.nodes.node1.kube.k3s.path
+ }
+ ```
+ read directly inside the `locals` block that feeds the provider config —
+ never through an `output`, per the stability contract's finding-#2
+ warning.
+6. **Ports lose their integer form** (`"host:port"` string) — show the
+ extraction idiom explicitly:
+ ```hcl
+ locals {
+ service1_port = split(":", local.tunnel.nodes.node1.ports.service1)[1]
+ }
+ ```
+7. **[R16] The stability contract**, restated plainly and matching the
+ design doc's "Stability contract" word-for-word on the load-bearing
+ claims: **both** Mode B forms — item 5's `TUNSTRAP_OUTPUT_FILE` form and
+ the `--output-var` (`var.tunstrap`) form — are **one-shot `plan && apply`
+ only**, no saved-plan reuse across a tunstrap restart for either, no
+ locator exemption of any kind (the check compares the variable's whole
+ value; the file itself is deleted at teardown alongside the rest of
+ `tunnel-data/`). An earlier revision of this item claimed item 5's form
+ was unconditionally plan-safe given a `--session-dir` precondition — that
+ precondition and the plan-safety it bought are both retracted; there is
+ no remaining path-pinning mechanism. State this as plainly as the design
+ doc does: *"Neither Mode B form survives a tunstrap restart. If you need
+ a saved plan to apply cleanly against fresh ports or fetched-file content,
+ re-run plan in the same tunstrap invocation."* Cite findings #1, #2 and #6
+ by number.
+8. **The `jsondecode`-not-JavaScript note** (U5), one sentence: consumption
+ is via HCL's `jsondecode`; there is no JS runtime anywhere in this stack.
+9. **[R16] The `fetch_files` warning is retracted, not carried forward.**
+ Two earlier revisions of this recipe disagreed on whether to keep this
+ warning (one dropped it, the next restored it while reshaping the
+ payload); iteration 7 resolves it as a class instead of restating it:
+ fetched content no longer rides `--output-var` or the materialized file at
+ all — only `{path, size, sha256}` does (design doc, "Fetched-file
+ materialization" and "Compatibility"). State instead: *"Fetched file
+ content never enters a Terraform variable or plan file — only its path,
+ size, and checksum do. Read the file itself at `fetch_files..path`
+ if you need its contents."*
+
+Match the existing file's structure (numbered/lettered subsections, HCL code
+fences, "Measured Terragrunt facts"-style attribution footers) — read the
+file's current shape before writing, do not introduce a new prose style.
+
+- [ ] **Step 3: Cross-check against the two artifacts, the design doc, and the drift guard**
+
+Confirm every measured fact restated in both new sections matches the committed
+provider-precedence spec, the ticket's
+own six findings, and the design doc's "Stability contract" subsection
+verbatim — no rewording that could drift from the source transcripts or
+introduce a second, subtly different phrasing of the same rule. This is a
+manual read-through, not a test.
+
+Then run the recipe↔module drift guard, which must stay green through both
+Step 0's shape migration and this step's new content (it fails loudly, by
+design, if the two documents disagree on a shared HCL block):
+
+```bash
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e/test_recipe_terragrunt.py -m e2e -q
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/recipe_terragrunt.md
+git commit -m "docs(recipe): kubeconfig-as-identity delivery + unified output + stability contract (#15)"
+```
+
+---
+
+### Task 7: Full gate pass
+
+**Files:** none (verification only).
+
+- [ ] **Step 1: Style/type/lint gates**
+
+```bash
+.venv/bin/black --check .
+.venv/bin/ruff format --check .
+.venv/bin/ruff check .
+.venv/bin/pylint tunstrap/
+.venv/bin/vulture tunstrap/
+.venv/bin/mypy --strict tunstrap
+```
+
+Expected: all clean. `vulture` has no whitelist file to update
+(`vulture_whitelist.py` was removed; `min_confidence = 80` in
+`pyproject.toml` — if `rename_identities`/`render_kube_env`/
+`render_unified_output` get flagged as unused, that means a call site is
+missing, not that a suppression is needed; conversely if `RunKubeTarget`,
+`render_env`, or `MultiNodeEnvUnsupported` are still importable from
+anywhere, `vulture`/`ruff` catching them as unused is the signal Task 5's
+deletions were incomplete). `pylint`'s `fail-under = 9.0` gate applies to
+the whole `tunstrap/` package score, not per-file.
+
+- [ ] **Step 2: Unit suite**
+
+```bash
+.venv/bin/pytest tests/unit -q
+```
+
+Expected: full pass. **Do not compare the count against any number recorded
+in this plan, the spike findings, or earlier iterations of this plan** — the
+pivot deletes a meaningful number of pre-existing tests (Task 5's retarget
+list) while adding others; both this plan's own earlier "475+N" guidance and
+the spike's 476 are stale baselines from before the scalar-channel removal.
+Run it and read the real number.
+
+- [ ] **Step 3: Integration suite**
+
+```bash
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/integration -m integration -q
+```
+
+Expected: full pass — **this claim was false in an earlier revision of this
+plan** ("no changes needed — this ticket touches no integration fixtures"),
+corrected here: Task 5 Step 7 retargets `test_run_env_io.py` and
+`test_cli_modes.py` for the exact same shape/scalar removal as the unit
+tier, and this is the tier that proves those retargets hold against the
+*real* console script and a real docker rig, not just `CliRunner`. If this
+step is reached with those retargets not yet landed, it will fail, correctly
+— that failure is not a flake to route around, it is Task 5 Step 7 being
+incomplete.
+
+- [ ] **Step 4: e2e suite**
+
+**This claim was also false in an earlier revision of this plan** ("run the
+tier unmodified as a regression check only"): Task 6 changes
+`tests/e2e/module/main.tf`, `rig.py`, `test_tofu_providers.py`, and
+`test_terragrunt_apply.py` to the `nodes.*.kube.*.path` shape — those are
+real code changes this tier must pass against, not a no-op regression check.
+
+```bash
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e -m e2e -q
+```
+
+Expected: full pass, including `test_recipe_terragrunt.py`'s drift guard
+(already run once in Task 6 Step 3; running the full tier here is the final
+confirmation nothing else regressed).
+
+**Separately, and still optional:** the design doc's "`e2e` coverage —
+optional, with rationale" describes a *different* piece of work — an
+e2e-level collision test proving the kube identity rename, which no task in
+this plan adds by default because Task 2's unit-level regression test
+already exercises that specific defect precisely. If a reviewer chose to add
+it anyway, it would be an extension of Task 2 (rewriting two kind
+kubeconfigs to a shared identity before feeding them to `tunstrap start`/
+`run`, per the design doc), not part of this step. Do not conflate the two:
+Task 6's shape-migration e2e changes are mandatory and verified by this
+step; the collision-specific e2e coverage is optional and, if added, is
+Task 2's concern, verified the same way this step already verifies the rest
+of the tier.
+
+- [ ] **Step 5: Final commit / PR**
+
+No further commit needed if Tasks 1-6 already committed cleanly and gates
+pass on the resulting tree. Open or update PR #13 against `feature/run-env-io`
+per the ticket's stated target; do not merge (org rule, per prior Phase-A
+rulings on this repo — curate and validate, leave the merge decision to the
+human reviewer).
+
+---
+
+## Self-Review
+
+**Spec coverage — kube part (Tasks 1-3, unchanged by the pivot):**
+- Ticket work item 1 (patch identity names, all three, cluster+user+context)
+ → Task 1. ✓
+- Ticket work item 2 (multi-node kube channel) → Task 3. ✓ (its original
+ "`MultiNodeEnvUnsupported` narrows to scalars" framing is itself
+ superseded by the pivot — see below, the class is removed entirely, not
+ narrowed.)
+- Ticket work item 3 (export provider-facing variables) → Task 3, per the
+ conditional contract (R1), **not** the ticket's own placeholder "superset"
+ suggestion — corrected in light of the provider findings that arrived after
+ the ticket was written. ✓
+- R1 (conditional, not superset; anti-drift guard extended for both
+ cardinalities) → Task 3 builds `render_kube_env`'s exact cardinality
+ contract; Task 5 re-scopes the guard's *other side* (`predicted_env_keys`
+ vs. `_build_child_env`, once `render_env` is deleted) — but note
+ `predicted_env_keys` **itself** ships its final, conservative formula
+ directly in Task 3 (R11, iteration 6), not deferred to Task 5. ✓
+- R3 (active-triple-only rename scope) → Task 1 (`rename_identities`'s scope,
+ `test_kube_rename.py`'s "ignored entries" case). ✓
+- R4 (naming scheme, no configurable prefix) → Task 1. ✓
+- R6 (correction: `patch_view` owns server-address patching, not
+ `dump_kubeconfig`) → encoded in Task 1's implementation guidance (no
+ `dump_kubeconfig` signature change) and in the design doc directly. ✓
+- R7 (mandatory unit collision test; e2e optional with rationale) → Task 2
+ (mandatory) + Task 7 Step 4's "separately, and still optional" paragraph
+ (explicit rationale for skipping the collision-specific e2e coverage by
+ default, disentangled from Task 6's now-mandatory e2e shape migration —
+ the two were conflated in an earlier revision of this plan; iteration 4
+ keeps them clearly distinct). ✓
+- Not covered by any ruling, found and closed here: `suppress_kubeconfig`
+ must drop all three kube env names once `KUBE_CONFIG_PATH`/`_PATHS` become
+ real exported channels → wired in Task 5's `_build_child_env` rewrite
+ (originally Task 4 in iteration 2; the mechanism moved when the pivot
+ merged kube-channel wiring into the same edit as scalar removal). ✓ (see
+ decision history #7.)
+
+**Spec coverage — the pivot (U1-U6, Tasks 4-6):**
+- U1 (unified node-qualified output contract replaces flat scalars) → Task 4
+ (shape + `render_unified_output`) + Task 5 (scalar deletion). Decision
+ history entry 10. ✓
+- U2 (delivery: var AND materialization, materialization primary) → Task 5
+ Step 4 (materialization write, unconditional). Decision history entry 11. ✓
+- U3 (scalar channel deprecated/removed, not "stays single-node"; disposition
+ of `render_env`/`predicted_env_keys`/`MultiNodeEnvUnsupported` worked out)
+ → Task 5 in full, **now backed by a grep-driven blast-radius table**
+ (iteration 4) covering unit, integration, e2e and the recipe doc — every
+ deletion and retarget is enumerated by file:line with a stated disposition,
+ not reconstructed from a prose list. Decision history entries 10 and 13. ✓
+- U4 (kube part unchanged; unified structure carries only kube references)
+ → Task 1-3 untouched; Task 4's `UnifiedKubeRef` shape enforces
+ reference-only fields at the model level (`extra="forbid"`, no credential
+ field exists to leak, and — confirmed in Task 5's dedicated retarget of
+ `test_cli_run_output_var_projection.py` — the field set is narrower than
+ the pre-#15 `RunKubeTarget` projection by design, not by omission: `path`/
+ `context`/`endpoint` only). ✓
+- U5 (consumer-side transformation via jsondecode; "через js" recorded as an
+ assumption) → design doc "Consumer-side transformation"; Task 6 Step 2's
+ recipe content states the same interpretation; decision history entry 12
+ records it as its own decision. ✓
+- U6 (reconciliation: ticket's "nothing live enters Terraform" holds for kube,
+ superseded for ports by materialization-primary + stability contract) →
+ design doc's dedicated reconciliation subsection (present in both the
+ problem framing and the unified-output section) + decision history entry
+ 11's "U6 reconciliation" paragraph, both present per the DoD's explicit
+ requirement that this appear in *both* documents. ✓
+- Sarge's ruling this iteration (`inject_scalars` gate semantics change:
+ unified output emitted regardless of node count) → design doc's rewritten
+ "`cli.py` wiring is in scope" subsection + Task 5's unconditional
+ `_build_child_env`; decision history entry 13. ✓
+- R5 (breaking deliberately) → extended by the pivot to cover the scalar
+ removal and `MultiNodeEnvUnsupported` deletion too, not just the kube
+ rename — design doc "Compatibility" section, pivot-tagged bullets. ✓
+- R8 (recipe carries the three conditions + the four measured facts) →
+ Task 6 Step 0 (fixes the recipe's own **pre-existing** `connections.*`
+ shape so it does not contradict the new content) + Step 1 (kube part,
+ unchanged) + Step 2 (pivot: shape, stability contract, jsondecode note). ✓
+- Iteration-2 ruling (kube channel fires on `kube_targets` presence, not node
+ count) → **superseded, not re-satisfied by a new mechanism**: under the
+ pivot there is no `inject_scalars` branch left to satisfy the ruling
+ *against*, so it holds trivially (Task 5's unconditional
+ `render_kube_env` call). The pre-existing test this ruling first
+ contradicted (originally `test_multi_node_suppression_uses_input_count`,
+ retargeted once in iteration 2 to
+ `test_multi_node_suppresses_scalars_but_exports_kube_channel`) is
+ retargeted a **second** time in Task 5 Step 2, to
+ `test_optional_node_failure_does_not_affect_kube_channel_or_unified_output`,
+ because its remaining `leaked == []` assertion stopped describing a real
+ guard once there was nothing left to leak from. Both retargets are
+ recorded by name in decision history (entry 9, marked superseded; entry
+ 13, the current disposition). ✓
+
+**Placeholder scan:** No task defers its own code to "TBD" with one
+explicitly-flagged exception: Task 5 Step 4's materialization call site asks
+the implementer to "confirm the exact `run_command` call site against the
+checked-out `cli.py`" rather than citing a line number, because this plan's
+own earlier line citations for that function have already drifted once
+across iterations (noted inline, Task 5 Step 4) — re-resolving against the
+live file is explicitly instructed, not a gap. Every other task either
+cherry-picks concrete, reviewed spike code (Task 1's function body, Task 3's
+`render_kube_env` skeleton before the cardinality-helper refactor) or
+specifies exact replacement logic inline (Task 3's `_kube_channel_keys`,
+Task 4's model definitions, Task 5's `_build_child_env` rewrite, Task 5's
+retargeted anti-drift guard given in full). Task 6 is documentation plus the
+e2e shape migration, both scoped to concrete file:line targets from the
+blast-radius table. Task 7 Step 4 is scoped precisely (mandatory e2e shape
+migration vs. optional collision coverage, disentangled — iteration 4).
+
+**Type consistency:** `rename_identities(dict[str,object], str, str) -> str`;
+`render_kube_env(OutputSchema) -> dict[str,str]`;
+`_kube_channel_keys(int) -> set[str]`; `render_unified_output(OutputSchema) ->
+dict[str, Any]`; `render_output_var(OutputSchema) -> str` (signature
+unchanged, body rewritten); `predicted_env_keys(InputSchema) -> set[str]`
+(return type unchanged, body simplified, now three-survivor-aware);
+`_build_child_env(output, *, output_var, input_env, suppress_kubeconfig=False)
+-> dict[str,str]` (**`inject_scalars` parameter removed** — every one of the
+three call sites that threaded it, `_build_child_env`/`_run_child`/
+`_supervise_child`/`run_command`, updated in the same task, Task 5, named
+individually rather than hedged); `_materialized_output_path(str) -> str`
+(new, shared between the env-var value and the writer so the two paths
+cannot independently drift). ✓
+
+**Under-specified for implementation, flagged rather than silently resolved:**
+only the exact `run_command` call-site line for the materialization write
+(Task 5 Step 4, noted inline — re-resolve against the live file rather than
+trust a citation that has already drifted once) and whether `SessionDir.
+_write_file` is directly reusable for `output.json` or needs a small sibling
+helper (Task 5 Step 4 item 4, both paths given). `start`'s `--output env`
+mode, which also called the now-deleted `render_env` and would otherwise
+break outright, is explicitly resolved in Task 5 Step 4 item 5
+(three-survivors-plus-kube-channel, matching `_build_child_env`'s own new
+shape, plus materialization) — not left as an open question.
+
+**Iteration-4 summary — the systemic fix this revision exists to make:** a
+full grep-driven enumeration (unit + integration + e2e + docs, commands given
+at the top of Task 5) replaced three rounds of case-by-case patching. The
+anti-drift guard (`test_predicted_env_keys_matches_render_env`) is retargeted,
+not deleted — the earlier deletion was itself a drill-caught defect in this
+plan, corrected here and cross-referenced from the design doc and decision
+history entry 13 so the three documents cannot silently re-diverge on this
+point again.
+
+**Iteration-5 correction:** the iteration-4 retargeted guard literal (at that
+point, a single `predicted_env_keys(schema) == set(actual)` full-equality
+assertion) was itself defective — `_build_child_env` starts from
+`dict(os.environ)`, so the comparison was unconditionally False against the
+real ambient environment of any test process. Fixed by isolating `os.environ`
+to `{}` via `monkeypatch.setattr(cli_mod.os, "environ", {})` before calling
+`_build_child_env`, with an explicit warning against subtracting `os.environ`
+back out after the call instead (silently under-checks a key that is both
+inherited and injected).
+
+**Iteration-6 correction (R11) — the single-equality guard itself is now
+split into two, per the "Anti-drift guard" section above; read the
+iteration-5 warning below in that light, not as still forbidding a subset
+check outright.** Iteration-5's warning against "relaxing the assertion to a
+subset check" applied to the **exact-cardinality** predictor that existed at
+the time — a subset check on top of an exact predictor really would have
+been a pure relaxation, hiding a real regression. R11 changes what
+`predicted_env_keys` computes (deliberately conservative, not exact), which
+changes what "correct" means for the comparison: the safety-envelope half of
+the now-two-part guard (Task 5, "Anti-drift guard — retargeted, not deleted,
+and now two-part") **is** a subset check, `set(actual) <=
+predicted_env_keys(schema)`, and that is *correct*, not a weakening — it is
+paired with a separate, still-full-equality formula test (Task 3) that
+guards the conservative formula's own correctness. The ambient-environment
+isolation fix from iteration 5 carries forward unchanged into the
+safety-envelope test's literal (given in full in Task 5, above).
+
+**Iteration-7 summary (R16) — supersedes parts of R9/R12/R13/R15, does not
+touch R1/R10/R11/R14.** The user's confirmed post-red-team direction plus
+one added constraint: delivery collapses from R9's three modes to two
+(mode 2, the literal-pinned-`--session-dir` file, is retracted —
+`TUNSTRAP_OUTPUT_FILE` becomes the primary env-carried locator instead);
+`fetch_files` content_b64 is removed from every consumer-facing channel
+(materialized to disk, `{path, size, sha256}` projected instead, mirroring
+the kube precedent R13's atomic-replace primitive already established);
+R15's re-adoption of #14 fix 1 is retracted (fix 4 survives, reshaped). The
+user's own constraint — the session dir stays mandatory lifecycle
+infrastructure regardless — is encoded as its own design-doc subsection, not
+folded silently into the stability contract where it could be missed. R1
+(anti-drift guard), R10 (naming collision), R11 (conservative predictor),
+and R14 (dangling context reference) are all independently verified
+unaffected by this iteration — checked explicitly (see "`predicted_env_keys`/
+anti-drift guard: checked, unaffected by R16," Task 5), not assumed safe by
+omission. Decision history entry 19 (new) records the full
+context/alternatives/decision/consequences; entries 14 and 18 carry
+correction annotations rather than being rewritten in place, matching this
+document's own established annotate-don't-rewrite discipline.
+
+**Self-Review updates, iteration 7:**
+- U2 ("delivery: var AND materialization, materialization primary") — still
+ holds for the *mechanism* (materialization primary, var secondary); the
+ *locator* for the materialized side changes from R9's caller-pinned path to
+ R16's env-carried `TUNSTRAP_OUTPUT_FILE`. Task 5 (mechanism) + Task 6 Step 2
+ (recipe) updated; decision history entry 19 records the correction.
+- U6 (reconciliation) — the "ports: genuine third option" bullet is corrected
+ in place (design doc, "Reconciliation," R16-tagged); the option itself
+ (`file()` at a locatable path) survives, its plan-safety-across-restart
+ property does not.
+- R8 (recipe carries the conditions + measured facts) — Task 6 Step 2's Mode
+ B content is rewritten a second time (R16), not just re-worded; the
+ measured-facts list itself (six ticket findings) is unchanged, only which
+ delivery mechanism each fact is cited in support of.
+- R9/R12/R13/R15 — explicitly **not** re-litigated from scratch; R16 is
+ scoped to exactly what changed (delivery mode count, the fetch_files
+ projection, the fix-1 re-adoption), stated as corrections layered on top,
+ per the ticket's own instruction for this iteration.
+
+**Iteration-8 note — a methodology regression in iteration 7's own
+enumeration, found by drill review and corrected here.** Iteration 7's
+`content_b64` blast-radius grep (Task 5, "Fetched-file materialization")
+was run as `tunstrap/ tests/ --include='*.py'` — narrower than the
+`tunstrap/ tests/ docs/ --include='*.py' --include='*.md' --include='*.tf'`
+scope iteration 4 established for the *original* blast-radius table at the
+top of Task 5, and that this R16-specific enumeration should have inherited
+by default rather than re-deriving from scratch. The missed `docs/` tier
+cost a whole shipped subsection (`docs/recipe_terragrunt.md:366-388`)
+arguing the opposite of what R16 ships — not caught until this iteration's
+drill pass. **Standing instruction for any future grep-driven enumeration in
+this plan: default to the widest previously-established scope for the same
+search term (`docs/` + `.md`/`.tf` included) and narrow only with an
+explicit, stated reason, never by silently reusing a shorter command from a
+different, earlier context.** Fixed in place: the grep command (Task 5),
+the enumeration table (new "docs tier" rows, Task 5), and a new Task 6 Step
+0b carrying the actual rewrite. R13's atomic-replace rationale was also
+re-grounded this iteration (design doc, two locations; plan, Task 5's
+materialization-writer step) — the requirement itself did not change, only
+its justification, which had come to rest on a race (`file()` racing a `run`
+restart against a pinned path) that R16 itself had already retired one
+iteration earlier without anyone circling back to the sentences that cited
+it.
diff --git a/docs/superpowers/plans/2026-08-08-issue15-kube-identity-clean.md b/docs/superpowers/plans/2026-08-08-issue15-kube-identity-clean.md
new file mode 100644
index 0000000..70f6a52
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-08-issue15-kube-identity-clean.md
@@ -0,0 +1,2323 @@
+# Kubeconfig-as-identity delivery (issue #15) — Implementation Plan
+
+> **Redaction/repoint note (2026-08-10):** Repointed the provider-evidence
+> citation to its committed spec and identified the spike notes as unpublished,
+> so this frozen plan does not claim ignored working files are committed.
+
+> **For agentic workers:** REQUIRED SUB-SKILL: use superpowers:subagent-driven-development
+> (recommended) or superpowers:executing-plans to implement this plan task-by-task.
+> Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Ticket:** AlexMKX/tunstrap#15 — rework kube delivery: deterministic context
+names + a unified, env-native output contract.
+
+**Target branch:** `feature/run-env-io` (PR #13). Every task below assumes a
+checkout of that branch as the working tree.
+
+**Spec:** `docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md`
+**Decision history (ADR, entries 1-19):**
+`docs/specs/2026-08-07-issue15-kube-identity-decisions.md` — all historical
+rationale (alternatives considered, why each rule is shaped the way it is)
+lives there; this plan states what to build, not how the design arrived at it.
+
+**Spike reference implementation:** branch `variant/combined` in the read-only
+scratch worktree ``
+(reviewed; 475/475 pre-existing unit tests pass plus one new regression test).
+**Cherry-pick the `kube.py` rename change from it as-is. Do NOT cherry-pick its
+`envrender.py` change:** the spike's env-export body is a naive superset export
+and must be replaced by the conditional cardinality contract given in Task 3.
+The spike covers the kube part only (Tasks 1-3); it prototypes nothing of the
+unified output contract, materialization, or the scalar-channel removal (Tasks
+4-6) — that is new code with no spike reference. Never commit from the spike
+worktree. Do not re-derive the six OpenTofu findings or the provider-precedence
+findings; the provider result is committed in
+`docs/specs/2026-08-10-issue15-provider-env-precedence.md`. The spike notes are
+unpublished working artifacts, not committed reference material.
+
+**Tech stack:** Python 3.10+, Pydantic v2, Click, ruamel.yaml, pytest +
+pytest-asyncio. Use `.venv/bin/{pytest,ruff,black,mypy,pylint,vulture}`.
+Integration/e2e tiers need Docker (+ `kind`/`kubectl`/`tofu` for e2e) on
+`PATH`; see `tests/README.md` for env flags.
+
+**Standing discipline for this plan:** the blast-radius tables in Task 5 are
+grep-driven, authoritative enumerations, not starting points. If you find
+something a table missed, **re-run the greps at the stated scope** — do not
+patch the single spot you found. Default any new enumeration to the widest
+established scope for the same search term (`tunstrap/ tests/ docs/` ×
+`--include='*.py' --include='*.md' --include='*.tf'`) and narrow only with an
+explicit, stated reason.
+
+---
+
+## The contract this plan implements
+
+**Core principle: content on disk, paths in env.** All content-bearing
+artifacts (patched kubeconfigs, the unified manifest, fetched files) are
+written under `/tunnel-data/` at mode `0600`; only locators
+travel through the environment.
+
+### Two delivery modes
+
+1. **Kube: env-native, always plan-safe.** `run` exports
+ `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` (plus `KUBECONFIG`) from its own
+ process environment, and the consumer pins a **literal**
+ `config_context = "tunstrap--"` per provider alias. No
+ Terraform variable and no `file()` read anywhere in the kube path, so a
+ saved plan applies cleanly (findings #1/#3/#4).
+2. **Everything else: the unified manifest file, located by
+ `TUNSTRAP_OUTPUT_FILE`.** `run` (and `start --output env`) exports
+ `TUNSTRAP_OUTPUT_FILE=/tunnel-data/output.json` as a plain
+ process env var; the consumer reads it with
+ `try(jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))), { nodes = {} })`.
+ The session dir is ephemeral and freshly minted per invocation, and the
+ file is deleted at teardown/`stop`, so this mode — and the `--output-var`
+ bridge below — is **one-shot `plan && apply` within a single tunstrap
+ invocation only**. No saved-plan reuse across a tunstrap restart, no
+ locator exemption (finding #6 compares the variable's whole bound value).
+ `--output-var NAME` (`TF_VAR_tunstrap`) survives only as a narrower
+ fallback for bare `tofu`, which cannot call `get_env(...)`; it carries the
+ same manifest under the same one-shot rule.
+
+Never read the manifest through a Terraform `output` block: outputs freeze
+silently at plan time (finding #2). Read it directly inside the
+provider/`locals` block that consumes it. Binding live data to a *resource*
+attribute (rather than a provider config block) produces `Error: Provider
+produced inconsistent final plan` — provider-block placement is the only
+supported shape (findings artifact, Q3).
+
+### The unified manifest shape
+
+```json
+{
+ "session": {
+ "session_dir": "/run/tunstrap/abc123",
+ "pid": 4711,
+ "started_at": "2026-08-07T00:00:00Z",
+ "warnings": []
+ },
+ "nodes": {
+ "node1": {
+ "ports": {"service1": "127.0.0.1:5432"},
+ "kube": {
+ "k3s": {
+ "path": "/run/tunstrap/abc123/tunnel-data/node1-k3s",
+ "context": "tunstrap-node1-k3s",
+ "endpoint": "https://127.0.0.1:41111"
+ }
+ },
+ "fetch_files": {
+ "hosts": {"path": "/run/tunstrap/abc123/tunnel-data/node1-hosts", "size": 6, "sha256": "..."}
+ }
+ }
+ }
+}
+```
+
+- Exactly two reserved top-level keys, `session` and `nodes` (a flat root
+ would let an operator-named node collide with them).
+- **Ports**: a plain `"host:port"` string per target — no integer form.
+- **Kube**: `{path, context, endpoint}` only — never credentials, never file
+ content. `context` is the post-rename `tunstrap--` name.
+- **`fetch_files`**: `{path, size, sha256}` on success, `{error}` on failure —
+ **never `content_b64`**. The daemon materializes fetched bytes to
+ `tunnel-data/-`.
+
+### Env keys `run` injects
+
+Three survivor scalars, unconditionally: `TUNSTRAP_SESSION_DIR`,
+`TUNSTRAP_PID`, `TUNSTRAP_OUTPUT_FILE`. Plus the kube channel when any kube
+target materialized. Every `TUNSTRAP__*` key is gone, along with
+`render_env`, `inject_scalars`, and `MultiNodeEnvUnsupported`. Multi-node
+input no longer requires `--output-var`.
+
+### Kube env-export cardinality (never the naive superset)
+
+| Materialized kubeconfig files | Exported keys |
+|---|---|
+| 0 | *(nothing)* |
+| exactly 1 | `KUBECONFIG` + `KUBE_CONFIG_PATH` |
+| ≥ 2 | `KUBECONFIG` + `KUBE_CONFIG_PATHS` (**no** `KUBE_CONFIG_PATH`) |
+
+Values are colon-joined (`:`) — comma silently degrades to `localhost:80`
+(finding #5). `KUBE_CONFIG_PATH` wins over `KUBE_CONFIG_PATHS` in the
+measured provider precedence, so exporting both once a second file exists
+would silently hide every cluster but the first (ADR entry 3).
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|------|----------------|
+| `tunstrap/kube.py` | New `rename_identities(doc, node, target) -> str`, sweeping all references incl. non-current contexts; one call site in `run_kube_targets` between `patch_view` and `dump_kubeconfig`. |
+| `tunstrap/schemas.py` | New `InputSchema`-level `model_validator` rejecting colliding `tunstrap--` pairs; new `UnifiedOutput`/`UnifiedSession`/`UnifiedNode`/`UnifiedKubeRef`/`UnifiedFetchRef` models; `FetchedFile` gains `path: str \| None = None`; `RunKubeTarget` deleted. |
+| `tunstrap/envrender.py` | New `_kube_channel_keys(count)` + `render_kube_env(output)` (conditional cardinality contract); new `render_unified_output(output)`; `render_output_var`'s body rewritten to serialize the unified structure; `predicted_env_keys` rewritten (conservative); `render_env` deleted. |
+| `tunstrap/exceptions.py` | `MultiNodeEnvUnsupported` and its `_EXIT_CODES` entry deleted. |
+| `tunstrap/cli.py` | `_build_child_env`: unconditional `render_kube_env(output)` call, three survivor scalars, `suppress_kubeconfig` drops all three kube names; new `_materialized_output_path` + atomic-replace materialization writer in `run_command`'s success path; `start --output env` rebuilt on the same mapping; the pre-spawn multi-node-without-`--output-var` gate deleted; `inject_scalars` removed from the whole call chain. |
+| `tunstrap/session.py` | Referenced; optionally gains a shared atomic-replace helper (temp file + `os.replace`) if `_write_file` can be refactored to be callable without a live `SessionDir`. |
+| `tunstrap/` (daemon/worker materialization site) | New fetched-file materialization step writing `tunnel-data/-` and setting `FetchedFile.path`. |
+| `tests/unit/test_kube_rename.py` | New. `rename_identities` as a pure function. |
+| `tests/unit/test_schemas_kube_naming_collision.py` | New. The join-collision validator. |
+| `tests/unit/test_kube_identity_collision.py` | New. The mandatory k3s-style upstream-name collision regression test. |
+| `tests/unit/test_kube_run.py` | Extend: `context_name`/`cluster_name` assert the renamed value. |
+| `tests/unit/test_envrender.py` | Extend/rewrite: `render_kube_env` cardinality cases; conservative `predicted_env_keys` cases; the safety-envelope half of the anti-drift guard; `render_unified_output` shape tests; every `render_env`-dependent test deleted by name. |
+| `tests/unit/test_cli_run_materialize.py` | New. Materialization writer tests. |
+| `tests/unit/test_cli_run_output_var.py`, `test_cli_run_output_var_projection.py`, `test_cli_run.py`, `test_cli_run_input_env_scrub.py`, `test_cli_runner.py`, `test_cli_run_postspawn.py`, `test_exceptions.py`, `test_tofu_proxy.py`, `test_manager_fetch.py`, `test_output_schema.py` | Retargeted per Task 5's blast-radius tables. |
+| `tests/integration/test_run_env_io.py`, `test_cli_modes.py` | Retargeted for the shape/scalar removal against the real console script. |
+| `tests/e2e/module/main.tf`, `rig.py`, `test_tofu_providers.py`, `test_terragrunt_apply.py` | Retargeted to `nodes.*.kube.*.path` (Task 6). |
+| `docs/recipe_terragrunt.md` | Pre-existing `connections.*` shape fixed; the fetched-files subsection rewritten; Mode A + Mode B consumer sections added. |
+
+---
+
+### Task 1: `rename_identities` + call site in `kube.py` + naming-collision check
+
+**Files:**
+- Modify: `tunstrap/kube.py`, `tunstrap/schemas.py`
+- Test: `tests/unit/test_kube_rename.py` (new), `tests/unit/test_kube_run.py`
+ (extend), `tests/unit/test_schemas_kube_naming_collision.py` (new)
+
+- [ ] **Step 1: Write failing tests**
+
+`tests/unit/test_kube_rename.py`:
+
+```python
+"""rename_identities: deterministic tunstrap-- identity rename.
+
+Validates: the current-context's cluster/user/context are all renamed to the
+same tunstrap-- string; non-current entries are untouched;
+current-context itself is updated; the return value is that shared name.
+Code: tunstrap/kube.py::rename_identities
+Assertion: post-call doc state matches exactly, including the untouched
+ignored entries; the returned name equals every renamed field.
+Method: build a minimal ruamel-shaped dict (plain dicts are sufficient; the
+function only calls .get/[]/isinstance) with two contexts, call the function,
+inspect doc afterwards.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from tunstrap.kube import rename_identities
+
+pytestmark = pytest.mark.unit
+
+
+def _doc() -> dict[str, object]:
+ return {
+ "current-context": "default",
+ "contexts": [
+ {"name": "default", "context": {"cluster": "default", "user": "default"}},
+ {"name": "other", "context": {"cluster": "other-c", "user": "other-u"}},
+ ],
+ "clusters": [
+ {"name": "default", "cluster": {"server": "https://127.0.0.1:1"}},
+ {"name": "other-c", "cluster": {"server": "https://127.0.0.1:2"}},
+ ],
+ "users": [
+ {"name": "default", "user": {}},
+ {"name": "other-u", "user": {}},
+ ],
+ }
+
+
+def test_renames_current_context_cluster_and_user_to_shared_name() -> None:
+ """All three identity fields get the same tunstrap-- value."""
+ doc = _doc()
+ new_name = rename_identities(doc, "node-a", "kube")
+ assert new_name == "tunstrap-node-a-kube"
+ assert doc["current-context"] == "tunstrap-node-a-kube"
+ ctx = doc["contexts"][0]
+ assert ctx["name"] == "tunstrap-node-a-kube"
+ assert ctx["context"]["cluster"] == "tunstrap-node-a-kube"
+ assert ctx["context"]["user"] == "tunstrap-node-a-kube"
+ assert doc["clusters"][0]["name"] == "tunstrap-node-a-kube"
+ assert doc["users"][0]["name"] == "tunstrap-node-a-kube"
+
+
+def test_ignored_entries_are_left_untouched() -> None:
+ """Non-current context/cluster/user entries survive byte-stable."""
+ doc = _doc()
+ rename_identities(doc, "node-a", "kube")
+ assert doc["contexts"][1] == {
+ "name": "other",
+ "context": {"cluster": "other-c", "user": "other-u"},
+ }
+ assert doc["clusters"][1]["name"] == "other-c"
+ assert doc["users"][1]["name"] == "other-u"
+
+
+def test_two_nodes_same_upstream_names_get_distinct_results() -> None:
+ """The exact k3s-style collision case: same input, different node -> different name."""
+ assert rename_identities(_doc(), "a", "kube") != rename_identities(_doc(), "b", "kube")
+
+
+def test_ignored_context_sharing_the_active_cluster_keeps_a_valid_reference() -> None:
+ """A non-current context that references the SAME cluster/user the active
+ triple uses must have that reference updated too, or it dangles -- naming
+ a cluster/user that no longer exists anywhere in the document under its
+ old name. The ignored context's own `name` is untouched (it is not
+ renamed itself, only its cluster/user references are); only entries that
+ neither ARE nor REFERENCE the active triple stay fully byte-stable."""
+ doc: dict[str, object] = {
+ "current-context": "default",
+ "contexts": [
+ {"name": "default", "context": {"cluster": "default", "user": "default"}},
+ # Shares the SAME cluster/user as the active context, under a
+ # different context name -- a legitimate, ordinary kubeconfig shape.
+ {"name": "staging", "context": {"cluster": "default", "user": "default"}},
+ ],
+ "clusters": [{"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}],
+ "users": [{"name": "default", "user": {}}],
+ }
+ new_name = rename_identities(doc, "node-a", "kube")
+ staging_ctx = doc["contexts"][1]
+ assert staging_ctx["name"] == "staging" # the ignored context's own name is untouched
+ assert staging_ctx["context"]["cluster"] == new_name # its reference is NOT left dangling
+ assert staging_ctx["context"]["user"] == new_name
+ # And the referenced entries genuinely exist under the new name.
+ assert doc["clusters"][0]["name"] == new_name
+ assert doc["users"][0]["name"] == new_name
+```
+
+Add to `tests/unit/test_kube_run.py` (extends the existing
+`test_run_kube_target_success`):
+
+```python
+@pytest.mark.asyncio
+async def test_run_kube_target_reports_renamed_identity(monkeypatch: pytest.MonkeyPatch) -> None:
+ """KubeTargetOutput.context_name/cluster_name are tunstrap--, not upstream."""
+ monkeypatch.setattr(
+ "tunstrap.kube.sans_from_cert",
+ lambda _der: (["dev-kube-1", "192.0.2.11"], []),
+ )
+ conn = _FakeConn((FIXTURES / "single_internal_ip.yaml").read_bytes())
+ outputs, _, _ = await run_kube_targets(
+ conn,
+ {"k3s": KubeTarget.model_validate({"kubeconfig_path": "/etc/k3s.yaml"})},
+ connect_timeout=5,
+ probe=_probe_ok,
+ node_name="edge",
+ )
+ out = outputs["k3s"]
+ assert out.context_name == "tunstrap-edge-k3s"
+ assert out.cluster_name == "tunstrap-edge-k3s"
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+`.venv/bin/pytest tests/unit/test_kube_rename.py tests/unit/test_kube_run.py -v`
+
+Expected: `test_kube_rename.py` fails on import (`rename_identities` missing);
+the new `test_kube_run.py` case fails because `context_name`/`cluster_name`
+still equal the fixture's upstream `"production"`.
+
+- [ ] **Step 3: Implement `rename_identities` in `kube.py`**
+
+Add after `patch_view`, before `dump_kubeconfig` (cherry-pick from
+`variant/combined`, `tunstrap/kube.py`, dropping the spike docstring's "V1c"
+framing; `__all__` gains `"rename_identities"`):
+
+```python
+def rename_identities(doc: dict[str, object], node: str, target: str) -> str:
+ """Rename the current-context's cluster/user/context to a deterministic name.
+
+ ``tunstrap--`` for cluster, user and context alike -- see
+ docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md. Operates on
+ the raw parsed document alone; the current-context's own name is enough to
+ find every entry that needs renaming.
+
+ Every *other* context's cluster/user REFERENCES are also updated if they
+ name the same cluster/user being renamed here -- a kubeconfig can
+ legitimately have two contexts sharing one cluster or user entry, and
+ leaving such a reference unrenamed while the entry it points at IS renamed
+ would dangle it. Only entries that neither are, nor reference, the active
+ triple are left untouched; other contexts' own `name` fields are never
+ renamed, only their `cluster`/`user` reference fields when they match.
+
+ Returns the new name (shared by cluster, user and context alike).
+ """
+ new_name = f"tunstrap-{node}-{target}"
+ current = doc.get("current-context")
+ assert isinstance(current, str)
+ contexts_raw = doc.get("contexts")
+ contexts: list[object] = contexts_raw if isinstance(contexts_raw, list) else []
+ ctx_entry = _find_named(contexts, current)
+ assert ctx_entry is not None
+ ctx_body = ctx_entry["context"]
+ assert isinstance(ctx_body, dict)
+ old_cluster = ctx_body["cluster"]
+ old_user = ctx_body["user"]
+ assert isinstance(old_cluster, str)
+ assert isinstance(old_user, str)
+
+ ctx_entry["name"] = new_name
+ ctx_body["cluster"] = new_name
+ ctx_body["user"] = new_name
+
+ cluster_entry = _find_named(doc.get("clusters") or [], old_cluster)
+ assert cluster_entry is not None
+ cluster_entry["name"] = new_name
+
+ user_entry = _find_named(doc.get("users") or [], old_user)
+ assert user_entry is not None
+ user_entry["name"] = new_name
+
+ doc["current-context"] = new_name
+
+ # Sweep every OTHER context for a reference to the cluster/user entries
+ # just renamed. That context's own `name` is not touched -- it is not
+ # becoming the current context, only its dangling reference is fixed.
+ for entry in contexts:
+ if entry is ctx_entry or not isinstance(entry, dict):
+ continue
+ other_body = entry.get("context")
+ if not isinstance(other_body, dict):
+ continue
+ if other_body.get("cluster") == old_cluster:
+ other_body["cluster"] = new_name
+ if other_body.get("user") == old_user:
+ other_body["user"] = new_name
+
+ return new_name
+```
+
+Wire the call site in `run_kube_targets` (replace the `patch_view` →
+`dump_kubeconfig` → `KubeTargetOutput` block):
+
+```python
+ patch_view(view, local_port=local_port, tls_server_name=tls_name, insecure=insecure)
+ assert isinstance(view.doc, dict) # parse_kubeconfig guaranteed this
+ new_identity = rename_identities(view.doc, node_name, name)
+ patched = dump_kubeconfig(view)
+ outputs[name] = KubeTargetOutput(
+ cluster_name=new_identity,
+ context_name=new_identity,
+```
+
+**Do not carry over the spike's `# type: ignore[arg-type]`** on this call —
+`view.doc` is typed `object` on `KubeconfigView` (dataclass field,
+`kube.py:56`) and `rename_identities` wants `dict[str, object]`; use the
+explicit `assert isinstance(view.doc, dict)` above instead, matching the
+pattern `patch_view` already uses two lines earlier (`kube.py:257`). This
+keeps `mypy --strict` clean without a suppression. `dump_kubeconfig`'s
+signature does not change — `patch_view` owns server-address patching.
+
+- [ ] **Step 4: Run to verify pass**
+
+`.venv/bin/pytest tests/unit/test_kube_rename.py tests/unit/test_kube_run.py tests/unit/test_kube_patch.py tests/unit/test_kube_parse.py tests/unit/test_kube_parse_invariants.py -v`
+
+Expected: all pass. (The last three files are the ones the spike confirmed are
+unaffected; this run is the regression check that it stayed true.)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tunstrap/kube.py tests/unit/test_kube_rename.py tests/unit/test_kube_run.py
+git commit -m "feat(kube): rename current-context identity to tunstrap-- (#15)"
+```
+
+- [ ] **Step 6: Write the failing naming-collision test**
+
+New file `tests/unit/test_schemas_kube_naming_collision.py`:
+
+```python
+"""tunstrap-- is NOT unique by construction.
+
+_FETCH_FILES_KEY_RE (schemas.py:11) permits internal hyphens in node/target
+identifiers, and the join itself uses a hyphen, so two DIFFERENT (node,
+target) pairs can render the SAME string: (node="a-b", target="c") and
+(node="a", target="b-c") both produce "tunstrap-a-b-c". This is a distinct
+defect class from the k3s-style collision test (Task 2) -- that test proves
+upstream kubeconfig names colliding is fixed by the rename; this test proves
+tunstrap's OWN naming scheme does not collide with itself, independent of any
+kubeconfig content at all.
+
+Code: tunstrap/schemas.py (validator, InputSchema level)
+Method: construct an InputSchema with exactly the a-b/c vs a/b-c pair and
+assert validation rejects it, naming both colliding pairs.
+"""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from tunstrap.schemas import InputSchema
+
+pytestmark = pytest.mark.unit
+
+
+def test_naming_join_collision_across_nodes_is_rejected() -> None:
+ """(node='a-b', target='c') and (node='a', target='b-c') both join to
+ tunstrap-a-b-c -- reject the whole payload, naming both colliding pairs."""
+ with pytest.raises(ValidationError) as excinfo:
+ InputSchema.model_validate(
+ {
+ "nodes": {
+ "a-b": {
+ "host": "h1", "user": "u", "ssh_password": "p",
+ "kube_targets": {"c": {"kubeconfig_path": "/etc/x.yaml"}},
+ },
+ "a": {
+ "host": "h2", "user": "u", "ssh_password": "p",
+ "kube_targets": {"b-c": {"kubeconfig_path": "/etc/y.yaml"}},
+ },
+ }
+ }
+ )
+ message = str(excinfo.value)
+ assert "tunstrap-a-b-c" in message
+ assert "a-b" in message and "c" in message # first colliding pair
+ assert "a" in message and "b-c" in message # second colliding pair
+
+
+def test_non_colliding_hyphenated_names_are_accepted() -> None:
+ """Anti-vacuity: hyphens alone don't trigger the check -- only an actual join collision does."""
+ InputSchema.model_validate(
+ {
+ "nodes": {
+ "node-one": {
+ "host": "h1", "user": "u", "ssh_password": "p",
+ "kube_targets": {"kube-a": {"kubeconfig_path": "/etc/x.yaml"}},
+ },
+ "node-two": {
+ "host": "h2", "user": "u", "ssh_password": "p",
+ "kube_targets": {"kube-b": {"kubeconfig_path": "/etc/y.yaml"}},
+ },
+ }
+ }
+ )
+```
+
+- [ ] **Step 7: Run to verify failure**
+
+`.venv/bin/pytest tests/unit/test_schemas_kube_naming_collision.py -v`
+
+Expected: FAIL — no such validator exists yet; the first test's payload is
+wrongly accepted today.
+
+- [ ] **Step 8: Implement the collision check in `schemas.py`**
+
+Add an `InputSchema`-level `model_validator(mode="after")` alongside the
+existing `_validate_auth` field validator (`schemas.py:278-289`) — this must
+run at `InputSchema` level, not per-`NodeInput`, since the collision is
+cross-node:
+
+```python
+@model_validator(mode="after")
+def _validate_kube_identity_names_are_unique(self) -> InputSchema:
+ """tunstrap-- is not unique by construction (hyphens are
+ legal in both node and target names); reject a payload where two
+ different (node, target) pairs join to the same rendered identity."""
+ seen: dict[str, tuple[str, str]] = {}
+ for node_name, node in self.nodes.items():
+ for target_name in node.kube_targets or {}:
+ joined = f"tunstrap-{node_name}-{target_name}"
+ if joined in seen:
+ other_node, other_target = seen[joined]
+ raise ValueError(
+ f"kube identity name collision: ({node_name!r}, {target_name!r}) "
+ f"and ({other_node!r}, {other_target!r}) both render {joined!r}"
+ )
+ seen[joined] = (node_name, target_name)
+ return self
+```
+
+Place it near `InputSchema`'s existing `_validate_auth` validator so both
+cross-node checks live together. Do not move it onto `NodeInput` even though
+`kube_targets` is a `NodeInput` field — single-node scope cannot see the
+collision.
+
+- [ ] **Step 9: Run to verify pass, then commit**
+
+`.venv/bin/pytest tests/unit/test_schemas_kube_naming_collision.py tests/unit/test_schemas.py tests/unit/test_schemas_kube.py -v`
+
+Expected: all pass.
+
+```bash
+git add tunstrap/schemas.py tests/unit/test_schemas_kube_naming_collision.py
+git commit -m "feat(schemas): reject tunstrap-- naming collisions (#15)"
+```
+
+---
+
+### Task 2: The mandatory collision regression test
+
+**Files:**
+- Create: `tests/unit/test_kube_identity_collision.py`
+
+This is the trap the design doc's testing contract calls out by name: it must
+land, unmodified in substance, regardless of how Task 1 was implemented.
+
+- [ ] **Step 1: Copy the spike's prototype under a repo-convention file name**
+
+Copy `tests/unit/test_issue15_context_collision.py` from the spike worktree
+(``, branch
+`variant/combined`) to `tests/unit/test_kube_identity_collision.py` in this
+checkout — **only the file is renamed** (this repo's other test files never
+carry an issue number, e.g. `test_kube_run.py`, `test_envrender.py`). **Keep
+the test function name unchanged**,
+`test_two_k3s_style_targets_get_distinct_deterministic_identities` — it is
+already descriptive. Drop the module docstring's "spike" framing, replacing it
+with a plain description; the content is otherwise correct verbatim (exact
+source reproduced in full in
+the untracked issue #15 spike notes, "Part 3").
+
+- [ ] **Step 2: Run to verify it is GREEN after Task 1**
+
+`.venv/bin/pytest tests/unit/test_kube_identity_collision.py -v`
+
+Expected: PASS. (It was confirmed RED against the unmodified branch and GREEN
+under `variant/combined`; this run confirms the same holds against this
+checkout's own Task 1 implementation.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/unit/test_kube_identity_collision.py
+git commit -m "test(kube): regression test for the k3s upstream-name collision trap (#15)"
+```
+
+---
+
+### Task 3: `render_kube_env` + the conditional env-export contract
+
+**Files:**
+- Modify: `tunstrap/envrender.py`
+- Test: `tests/unit/test_envrender.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Add to `tests/unit/test_envrender.py` (uses the existing `_kube_out` helper):
+
+```python
+def test_render_kube_env_zero_files_returns_empty() -> None:
+ """No kube_targets anywhere -> no keys at all."""
+ out = OutputSchema(
+ connections={"h": NodeOutput(ports={"db": 1})},
+ pid=1, session_dir="/s", started_at="now",
+ )
+ assert render_kube_env(out) == {}
+
+
+def test_render_kube_env_one_file_sets_path_not_paths() -> None:
+ """Exactly one materialized file: KUBECONFIG + KUBE_CONFIG_PATH, no _PATHS."""
+ out = OutputSchema(
+ connections={"h": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/k3s")})},
+ pid=1, session_dir="/s", started_at="now",
+ )
+ env = render_kube_env(out)
+ assert env == {"KUBECONFIG": "/s/k3s", "KUBE_CONFIG_PATH": "/s/k3s"}
+ assert "KUBE_CONFIG_PATHS" not in env
+
+
+def test_render_kube_env_two_files_sets_paths_not_path() -> None:
+ """Two materialized files (could be one node, two targets, or two nodes):
+ KUBECONFIG + KUBE_CONFIG_PATHS, no _PATH -- KUBE_CONFIG_PATH would win over
+ KUBE_CONFIG_PATHS per the measured provider precedence and hide the second
+ cluster."""
+ out = OutputSchema(
+ connections={
+ "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/a-k3s")}),
+ "b": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7001, "/s/b-k3s")}),
+ },
+ pid=1, session_dir="/s", started_at="now",
+ )
+ env = render_kube_env(out)
+ assert env == {"KUBECONFIG": "/s/a-k3s:/s/b-k3s", "KUBE_CONFIG_PATHS": "/s/a-k3s:/s/b-k3s"}
+ assert "KUBE_CONFIG_PATH" not in env
+
+
+def test_predicted_env_keys_reserves_all_three_for_one_kube_target() -> None:
+ """predicted_env_keys is a CONSERVATIVE predictor, not exact: it reserves
+ all three kube names whenever ANY kube_targets are declared, regardless of
+ exact count -- input cardinality can shrink by output time (an optional
+ node/target can fail), so predicting the exact one-file branch here would
+ under-reserve KUBE_CONFIG_PATHS for a schema that later, at runtime,
+ actually produces >=2 files. render_kube_env's own export (tested above)
+ stays exact -- only the predictor is conservative."""
+ schema = InputSchema.model_validate(
+ {
+ "nodes": {
+ "node": {
+ "host": "h.example.net", "user": "u", "ssh_password": "p",
+ "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}},
+ }
+ }
+ }
+ )
+ keys = predicted_env_keys(schema)
+ assert {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} <= keys
+
+
+def test_predicted_env_keys_reserves_all_three_for_two_kube_targets_one_node() -> None:
+ """Same conservative reservation for the >=2 case -- the point is that BOTH
+ cardinalities reserve identically (all three), which is what makes the
+ predictor a safe over-approximation rather than a second exact
+ implementation of _kube_channel_keys."""
+ schema = InputSchema.model_validate(
+ {
+ "nodes": {
+ "node": {
+ "host": "h.example.net", "user": "u", "ssh_password": "p",
+ "kube_targets": {
+ "a": {"kubeconfig_path": "/etc/a.yaml"},
+ "b": {"kubeconfig_path": "/etc/b.yaml"},
+ },
+ }
+ }
+ }
+ )
+ keys = predicted_env_keys(schema)
+ assert {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} <= keys
+```
+
+**Why the predictor is conservative (and why the guard is two-part).**
+`predicted_env_keys` runs pre-spawn against *input* cardinality, but an
+optional (`required: false`) node or kube target can fail without failing the
+run, so *output* cardinality can be smaller than what was declared. Two kube
+targets declared (which an exact predictor would map to the `≥2` branch,
+`KUBE_CONFIG_PATHS` only) but one optional node fails at connect time → only
+one file materializes → the real export uses the `==1` branch
+(`KUBE_CONFIG_PATH`), which an exact predictor never reserved. A
+`--output-var KUBE_CONFIG_PATH` would then pass the pre-spawn collision check
+and be **silently overwritten** post-spawn. Hence: reserve **all three** kube
+names whenever *any* `kube_targets` are declared. Over-reserving is the safe
+direction (a false-positive usage error, cheap and visible) versus
+under-reserving (a silent post-spawn collision). The two tests above are the
+**formula half** of the anti-drift guard; the **safety-envelope half**
+(`actual ⊆ predicted`, driven by a cardinality-shrink case) needs
+`_build_child_env` to exist and is therefore added in Task 5. See ADR entry 16.
+
+- [ ] **Step 2: Run to verify failure**
+
+`.venv/bin/pytest tests/unit/test_envrender.py -v`
+
+Expected: FAIL — `render_kube_env` missing; `predicted_env_keys` still uses
+the unconditional `KUBECONFIG`-only rule.
+
+- [ ] **Step 3: Implement in `envrender.py`**
+
+Add the shared cardinality helper (used by `render_kube_env`, so the export
+rule lives in exactly one place):
+
+```python
+def _kube_channel_keys(count: int) -> set[str]:
+ """Names of the kube-channel env keys the conditional contract exports.
+
+ 0 files: nothing. Exactly 1: KUBECONFIG + KUBE_CONFIG_PATH. >=2:
+ KUBECONFIG + KUBE_CONFIG_PATHS. KUBE_CONFIG_PATH and KUBE_CONFIG_PATHS are
+ never both present -- KUBE_CONFIG_PATH wins over KUBE_CONFIG_PATHS per the
+ measured OpenTofu kubernetes/helm provider precedence (docs/specs/
+ 2026-08-10-issue15-provider-env-precedence.md), so exporting both once a
+ second file exists would silently hide every cluster but the first.
+ """
+ if count == 0:
+ return set()
+ if count == 1:
+ return {"KUBECONFIG", "KUBE_CONFIG_PATH"}
+ return {"KUBECONFIG", "KUBE_CONFIG_PATHS"}
+
+
+def render_kube_env(output: OutputSchema) -> dict[str, str]:
+ """Build the node-count-agnostic kube channel: KUBECONFIG plus the
+ OpenTofu-provider-facing var the conditional contract picks.
+
+ This channel has no node dimension: it collects one materialized path per
+ kube_target across every node, so it is safe to call for any node count.
+ """
+ kube_paths: list[str] = []
+ for node in output.connections.values():
+ for kname, target in node.kube_targets.items():
+ if target.path is None:
+ raise ValueError(f"kube target {kname!r} not materialized; cannot set KUBECONFIG")
+ kube_paths.append(target.path)
+ if not kube_paths:
+ return {}
+ joined = ":".join(kube_paths)
+ return {key: joined for key in _kube_channel_keys(len(kube_paths))}
+```
+
+Replace `render_env`'s inline kube-path block so it delegates to
+`render_kube_env` (`render_env` is still alive at this point — Task 5 deletes
+it). **This also deletes the now-unused `kube_paths: list[str] = []`
+accumulator declaration at `envrender.py:49`** — the block below never appends
+to it (that accumulation moved into `render_kube_env`), and leaving the
+declaration would fail `ruff check` (unused variable) at Task 7's gate:
+
+```python
+ for kname, target in node.kube_targets.items():
+ base = _key(kname)
+ if target.path is None:
+ raise ValueError(f"kube target {kname!r} not materialized; cannot set KUBECONFIG")
+ put(f"TUNSTRAP_{base}_KUBECONFIG", target.path)
+ put(f"TUNSTRAP_{base}_ENDPOINT", target.endpoint)
+
+ for key, value in render_kube_env(output).items():
+ put(key, value)
+ return env
+```
+
+Update `predicted_env_keys` to reserve conservatively. Unlike
+`render_kube_env`'s own export (exact, because it runs *after* real
+materialization and knows the true count), this runs pre-spawn against the
+*input* schema:
+
+```python
+def predicted_env_keys(schema: InputSchema) -> set[str]:
+ keys: set[str] = set()
+ if len(schema.nodes) == 1:
+ (node,) = schema.nodes.values()
+ keys.update({"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID"})
+ for tname in node.remote_targets:
+ base = _key(tname)
+ keys.update(
+ {f"TUNSTRAP_{base}_HOST", f"TUNSTRAP_{base}_PORT", f"TUNSTRAP_{base}_ENDPOINT"}
+ )
+ for kname in node.kube_targets or {}:
+ base = _key(kname)
+ keys.update({f"TUNSTRAP_{base}_KUBECONFIG", f"TUNSTRAP_{base}_ENDPOINT"})
+ if any(node.kube_targets for node in schema.nodes.values()):
+ keys.update({"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"})
+ return keys
+```
+
+Task 5 rewrites this function's body again — **not** to change the kube
+cardinality rule (already final and conservative here), but because the whole
+`TUNSTRAP_*` scalar half (`if len(schema.nodes) == 1:`) disappears with the
+scalar channel. The conservative kube-reservation line survives that rewrite
+verbatim.
+
+Also correct the docstring claim at `envrender.py:83-93` that "multi-node
+input injects no scalars at all, so the answer there is the empty set" — now
+false for the kube-channel keys; it stays true only for `TUNSTRAP_*` scalars.
+
+- [ ] **Step 4: Run to verify pass**
+
+`.venv/bin/pytest tests/unit/test_envrender.py -v`
+
+Expected: all pass, including every pre-existing case — single-node
+`KUBECONFIG` behaviour is byte-identical for the one-kube-target case, since
+`_kube_channel_keys(1)` includes `KUBECONFIG` exactly as the old
+unconditional `put("KUBECONFIG", ...)` did.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tunstrap/envrender.py tests/unit/test_envrender.py
+git commit -m "feat(envrender): multi-node kube channel + conditional KUBE_CONFIG_PATH(S) export (#15)"
+```
+
+---
+
+### Task 4: The unified output contract — shape + `render_unified_output`
+
+Pure-function work only: no `cli.py` wiring and no materialization yet (both
+Task 5). This task makes the shape exist and be correctly built from an
+`OutputSchema`.
+
+**Files:**
+- Modify: `tunstrap/schemas.py` (new models), `tunstrap/envrender.py`
+ (`render_unified_output`, `render_output_var` body rewritten)
+- Test: `tests/unit/test_envrender.py` (new cases)
+
+- [ ] **Step 1: Write failing tests**
+
+Add to `tests/unit/test_envrender.py`:
+
+```python
+def test_render_unified_output_shape() -> None:
+ """Ports become 'host:port' strings; kube becomes {path,context,endpoint}
+ references; fetch_files becomes {path,size,sha256} -- NOT a content_b64
+ passthrough; two reserved top-level keys."""
+ out = OutputSchema(
+ connections={
+ "node1": NodeOutput(
+ ports={"service1": 5432},
+ kube_targets={
+ "k3s": _kube_out_full(
+ 7000, "/s/tunnel-data/node1-k3s", context="tunstrap-node1-k3s"
+ )
+ },
+ fetch_files={
+ # .path is set here because materialization (Task 5) runs
+ # before render_unified_output ever sees this object -- the
+ # daemon writes the bytes and sets .path, exactly as it
+ # already does for KubeTargetOutput.path today.
+ "hosts": FetchedFile(
+ content_b64="aG9zdHM=", size=6, sha256="ab" * 32,
+ path="/s/tunnel-data/node1-hosts",
+ )
+ },
+ )
+ },
+ pid=42,
+ session_dir="/s",
+ started_at="2026-08-07T00:00:00Z",
+ )
+ unified = render_unified_output(out)
+ assert unified["session"] == {
+ "session_dir": "/s",
+ "pid": 42,
+ "started_at": "2026-08-07T00:00:00Z",
+ "warnings": [],
+ }
+ node = unified["nodes"]["node1"]
+ assert node["ports"] == {"service1": "127.0.0.1:5432"}
+ assert node["kube"]["k3s"] == {
+ "path": "/s/tunnel-data/node1-k3s",
+ "context": "tunstrap-node1-k3s",
+ "endpoint": "https://127.0.0.1:7000",
+ }
+ # {path, size, sha256} exactly -- no content_b64 in the projection.
+ assert node["fetch_files"]["hosts"] == {
+ "path": "/s/tunnel-data/node1-hosts", "size": 6, "sha256": "ab" * 32,
+ }
+ # Nothing that could carry raw content -- kube credentials AND fetched
+ # file content_b64 -- ever appears anywhere in the shape.
+ dumped = json.dumps(unified)
+ for leaked in ("client_certificate_data", "client_key_data", "content_b64"):
+ assert leaked not in dumped
+
+
+def test_render_unified_output_multi_node() -> None:
+ """Node dimension is a nested key: two nodes, two independent bodies."""
+ out = OutputSchema(
+ connections={
+ "a": NodeOutput(ports={"db": 1}),
+ "b": NodeOutput(ports={"db": 2}),
+ },
+ pid=1, session_dir="/s", started_at="now",
+ )
+ unified = render_unified_output(out)
+ assert set(unified["nodes"]) == {"a", "b"}
+ assert unified["nodes"]["a"]["ports"]["db"] == "127.0.0.1:1"
+ assert unified["nodes"]["b"]["ports"]["db"] == "127.0.0.1:2"
+
+
+def test_render_output_var_serializes_the_unified_shape() -> None:
+ """render_output_var's return value decodes to the same shape render_unified_output builds."""
+ out = OutputSchema(
+ connections={"h": NodeOutput(ports={"db": 1})},
+ pid=1, session_dir="/s", started_at="now",
+ )
+ decoded = json.loads(render_output_var(out))
+ assert decoded == render_unified_output(out)
+```
+
+(`_kube_out_full` is a small extension of the file's existing `_kube_out`
+helper that also accepts a `context` kwarg — add it alongside `_kube_out`; do
+not change `_kube_out`'s signature, Task 3's tests still use it unchanged.)
+
+- [ ] **Step 2: Run to verify failure**
+
+`.venv/bin/pytest tests/unit/test_envrender.py -k "unified" -v`
+
+Expected: FAIL — `render_unified_output` missing; `render_output_var` still
+returns the old `RunKubeTarget`-projection shape.
+
+- [ ] **Step 3: Implement**
+
+Add models to `tunstrap/schemas.py` (placed there, not `envrender.py`,
+matching the existing convention that `schemas.py` is the "Single source of
+JSON shape" per its own module docstring, `schemas.py:1`):
+
+```python
+class UnifiedKubeRef(BaseModel):
+ """Kube reference in the unified output: never credentials, never content."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ path: str | None
+ context: str
+ endpoint: str
+
+
+class UnifiedSession(BaseModel):
+ """Session metadata block of the unified output."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ session_dir: str
+ pid: int
+ started_at: str
+ warnings: list[TunnelWarning] = Field(default_factory=list)
+
+
+class UnifiedFetchRef(BaseModel):
+ """Fetched-file reference in the unified output: never content_b64,
+ mirroring UnifiedKubeRef's own credential/content narrowing. Success and
+ error are mutually exclusive, matching FetchedFile's own xor -- but this
+ model has no validator enforcing it, because render_unified_output (below)
+ is the only place that constructs one, from an already-validated
+ FetchedFile, per exactly the same explicit-keyword-construction pattern
+ UnifiedKubeRef already uses instead of a second runtime check."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ path: str | None = None
+ size: int | None = None
+ sha256: str | None = None
+ error: str | None = None
+
+
+class UnifiedNode(BaseModel):
+ """One node's body in the unified output: ports, kube refs, fetch_files."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ ports: dict[str, str] = Field(default_factory=dict)
+ kube: dict[str, UnifiedKubeRef] = Field(default_factory=dict)
+ fetch_files: dict[str, UnifiedFetchRef] = Field(default_factory=dict)
+
+
+class UnifiedOutput(BaseModel):
+ """The entire consumer-facing output: two reserved top-level keys."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ session: UnifiedSession
+ nodes: dict[str, UnifiedNode]
+```
+
+Add to `tunstrap/envrender.py`:
+
+```python
+def render_unified_output(output: OutputSchema) -> dict[str, Any]:
+ """Build the unified, node-qualified structure (design doc, "Unified
+ output contract"). Ports become 'host:port' strings; kube becomes
+ {path, context, endpoint} references (never credentials, never content);
+ fetch_files becomes {path, size, sha256} (or {error}) -- NOT a
+ passthrough: content must not enter a Terraform variable or the
+ materialized manifest, only its path/metadata may. Callers must ensure
+ fetch_files entries are already materialized (.path set) before calling
+ this -- see Task 5's fetched-file materialization step, which runs
+ upstream of this function, the same ordering KubeTargetOutput.path
+ already requires today.
+ """
+ nodes: dict[str, object] = {}
+ for node_name, node in output.connections.items():
+ kube = {
+ kname: UnifiedKubeRef(
+ path=target.path, context=target.context_name, endpoint=target.endpoint
+ ).model_dump()
+ for kname, target in node.kube_targets.items()
+ }
+ ports = {tname: f"127.0.0.1:{port}" for tname, port in node.ports.items()}
+ fetch_files = {
+ fname: (
+ UnifiedFetchRef(error=f.error).model_dump(exclude_none=True)
+ if f.error is not None
+ else UnifiedFetchRef(path=f.path, size=f.size, sha256=f.sha256)
+ .model_dump(exclude_none=True)
+ )
+ for fname, f in node.fetch_files.items()
+ }
+ nodes[node_name] = UnifiedNode(
+ ports=ports, kube=kube, fetch_files=fetch_files
+ ).model_dump()
+ session = UnifiedSession(
+ session_dir=output.session_dir,
+ pid=output.pid,
+ started_at=output.started_at,
+ warnings=output.warnings,
+ ).model_dump(mode="json")
+ return {"session": session, "nodes": nodes}
+```
+
+**`exclude_none=True`, deliberately**: without it a success entry would
+serialize `{"path": ..., "size": ..., "sha256": ..., "error": null}` — a stray
+`"error": null` in every successful fetch, not matching the contract's shape
+(`{"path", "size", "sha256"}` exactly) or the error-branch shape (`{"error"}`
+exactly). `UnifiedKubeRef` needs no such treatment: none of its three fields
+is ever optional/`None` in a materialized `KubeTargetOutput`.
+
+Replace `render_output_var`'s body (signature unchanged, `OutputSchema -> str`
+— no `cli.py` call-site change needed):
+
+```python
+def render_output_var(output: OutputSchema) -> str:
+ """Serialise the unified structure for ``--output-var``.
+
+ Delivers the same content the materialized file carries (Task 5) -- see
+ docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md, "The
+ unified output contract", for the delivery/stability contract governing
+ which of the two a plan-safe consumer should actually bind to.
+ """
+ return json.dumps(render_unified_output(output), separators=(",", ":"))
+```
+
+Delete the old `RunKubeTarget`-based body (the `payload =
+output.model_dump(mode="json")` / per-node `RunKubeTarget.model_validate`
+loop) — fully replaced, not kept as a fallback.
+
+**`RunKubeTarget` disposition:** now unused by `render_output_var`. Check with
+`vulture` (Task 7) whether anything else still imports it; if not, delete the
+class from `schemas.py` too — its whole purpose (an allow-list projection for
+this exact channel) is now served by `UnifiedKubeRef`.
+
+- [ ] **Step 4: Run to verify pass**
+
+`.venv/bin/pytest tests/unit/test_envrender.py -v`
+
+Expected: all pass. The **old** `render_output_var` shape tests in
+`tests/unit/test_cli_run_output_var.py` (e.g.
+`test_output_var_carries_the_whole_envelope_minus_kube_credentials`) now fail,
+expectedly — they pin the old `connections..ports.` (int) shape;
+Task 5 retargets them alongside the rest of that file's changes. Do not fix
+them here; note the expected failures and move on (one clean commit per
+concern).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tunstrap/schemas.py tunstrap/envrender.py tests/unit/test_envrender.py
+git commit -m "feat(envrender): unified node-qualified output contract, shape only (#15)"
+```
+
+---
+
+### Task 5: Materialize the unified output; remove the scalar channel
+
+**This is the big ripple task.** It does six things in one coherent change,
+because they are the same edit site (`_build_child_env` and its callers) or
+its direct sibling (the daemon-side materialization step):
+
+(a) wires `render_unified_output`/`render_output_var` into `run` and adds
+unconditional materialization of `output.json`;
+(b) collapses the kube-channel call to unconditional (ADR entry 13);
+(c) deletes `render_env`, `MultiNodeEnvUnsupported`, and `inject_scalars`;
+(d) **re-scopes** (not deletes) the `predicted_env_keys` anti-drift guard so
+it compares against `_build_child_env`'s actual output;
+(e) retargets every pre-existing test, fixture, and shipped artifact this
+removal breaks, across every tier — enumerated exhaustively below;
+(f) materializes `fetch_files` content to `tunnel-data/-` the
+same way kube files already are, removing `content_b64` from every
+consumer-facing projection.
+
+**Read before starting.** The tables below are a full grep-driven enumeration
+across `tunstrap/`, `tests/unit`, `tests/integration`, `tests/e2e`, and
+`docs/`. **Treat them as complete; if you find something they missed, that is
+a signal to re-run the greps, not to patch the one spot found.**
+
+Re-derivable with (or equivalent):
+
+```bash
+grep -rn 'TUNSTRAP_[A-Z0-9_]*' tunstrap/ tests/ docs/ \
+ --include='*.py' --include='*.md' --include='*.tf' \
+ | grep -vE 'TUNSTRAP_SESSION_DIR|TUNSTRAP_PID|TUNSTRAP_OUTPUT_FILE|TUNSTRAP_INPUT|TUNSTRAP_E2E_REQUIRE_ALL|TUNSTRAP_TOKEN'
+grep -rn 'MultiNodeEnvUnsupported\|inject_scalars\|render_env(' tunstrap/ tests/ --include='*.py'
+grep -rn 'connections\.' tests/ docs/ --include='*.py' --include='*.md' --include='*.tf'
+grep -rn '\["connections"\]\|\.connections\[' tests/ --include='*.py'
+grep -rn 'content_b64' tunstrap/ tests/ docs/ --include='*.py' --include='*.md' --include='*.tf'
+```
+
+#### Blast-radius table — unit tier (authoritative; every hit has a disposition)
+
+| File:line | Old shape/symbol | Disposition |
+|---|---|---|
+| `test_cli_run.py:91` | `FakePopen.last_env["TUNSTRAP_DB_PORT"] == "5432"` | Retarget: assert `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`/`TUNSTRAP_OUTPUT_FILE` present, `TUNSTRAP_DB_PORT` absent. |
+| `test_cli_run_input_env_scrub.py:156` | `env["TUNSTRAP_DB_PORT"] == "5432"`, "the injected scalars must survive the scrub" | Retarget: assert `TUNSTRAP_SESSION_DIR` survives the scrub instead; same docstring claim, different scalar. |
+| `test_cli_run_input_env_scrub.py:174` | `json.loads(env[VAR])["pid"] == 99` | Retarget: `json.loads(env[VAR])["session"]["pid"] == 99` — `pid` moved under the unified structure's `session` key. |
+| `test_cli_runner.py:392` (+docstring at ~360) | `"export TUNSTRAP_DB_PORT='5432'" in res.output` — the `start --output env` pin | **Fix the existing assertion**, not just "add a test": replace with the new three-survivors-plus-kube-channel export set; drop `TUNSTRAP_DB_PORT`/`TUNSTRAP_WEB_PORT`-style lines from any fixture the test builds. |
+| `test_cli_run_postspawn.py:955,993` (`test_lone_optional_node_failure_keeps_its_own_exit_code`) | Asserts `error["error"] == "MultiNodeEnvUnsupported"` for a lone optional node's failure (`connections == {}` trips `render_env`'s `!= 1` guard) | Retarget completely; the new behaviour is the opposite: `_build_child_env` no longer branches on connection count, so this **succeeds** (exit 0). Rename to `test_lone_optional_node_failure_still_succeeds_with_only_a_warning`; assert exit 0, `session.warnings` (via `--output-var`) carries the "edge" failure, teardown ran exactly once. |
+| `test_cli_run_output_var.py` (multiple) | See Step 2's per-test list | Retarget/delete per that list. |
+| `test_cli_run_output_var_projection.py` (whole file) | `RunKubeTarget` import; `["connections"]["node"]["kube_targets"]["k3s"]`; `decoded["pid"]`/`["session_dir"]`/`["started_at"]`/`["connections"]["node"]["ports"]` | Security-critical (credential-scrubbing pin) — see the dedicated sub-section below, not a one-line note. |
+| `test_envrender.py:4` | `from tunstrap.exceptions import MultiNodeEnvUnsupported` | Delete the import — `ruff` F401 once every user of it in this file is gone. |
+| `test_envrender.py` (`render_env`-dependent) | `test_render_ports_and_session`, `test_render_kube_sets_kubeconfig`, `test_render_kube_not_materialized_raises`, `test_render_requires_single_node_zero`, `test_render_requires_single_node_two` | Delete all five — each asserts on `render_env`, which no longer exists. Do not add any new `render_env`-asserting test in Task 3 either; there is nothing left for one to pin. |
+| `test_envrender.py::test_predicted_env_keys_matches_render_env` | Compares `predicted_env_keys` against `render_env`'s output | **Retarget, not delete** — see "Anti-drift guard" below. |
+| `test_envrender.py` (predicted_env_keys shape) | `test_predicted_env_keys_no_kube_omits_kubeconfig`, `test_predicted_env_keys_multi_node_is_empty` | Delete — both pin the old per-target scalar enumeration / the "multi-node is empty" claim, false under the new unconditional `{session scalars} ∪ kube-channel` contract. Replaced by `test_predicted_env_keys_is_session_scalars_plus_kube_channel` and `test_predicted_env_keys_no_kube_is_just_the_three_survivors` (Step 2). |
+| `test_exceptions.py:87-90` | `issubclass(MultiNodeEnvUnsupported, TunstrapError)` subclass test | Delete. |
+| `test_exceptions.py:94-99` | Exit-code + envelope test constructing `MultiNodeEnvUnsupported(...)` | Delete. |
+| `test_exceptions.py:107-114` | `_EXIT_CODES[MultiNodeEnvUnsupported] == 1` table test | Delete. All **three** cases named explicitly. |
+| `test_tofu_proxy.py:375,386,397,399` | Docstrings framing the pop in terms of `inject_scalars`/`render_env` | Update docstrings only (mechanism note, not an assertion change) — both `test_tunnelled_suppresses_kubeconfig_in_child_env` and `test_tunnelled_drops_an_inherited_kubeconfig_in_the_multi_node_case`. |
+
+#### Blast-radius table — integration tier
+
+| File:line | Old shape/symbol | Disposition |
+|---|---|---|
+| `test_run_env_io.py:49-50` (`_PROBE_SINGLE`) | `os.environ["TUNSTRAP_WEB_PORT"]` | Retarget: probe reads `json.load(open(os.environ["TUNSTRAP_OUTPUT_FILE"]))["nodes"]["hub"]["ports"]["web"]` (a `"host:port"` string; `.rsplit(":", 1)[1]` for the port). |
+| `test_run_env_io.py` `_PROBE_MULTI` | `envelope["connections"][name]["ports"]["web"]` | Retarget to `envelope["nodes"][name]["ports"]["web"]` (string, parsed as above). |
+| `test_run_env_io.py` `_PROBE_MULTI` leak check | `k.startswith("TUNSTRAP_") and k != "TUNSTRAP_INPUT"` — now wrongly flags the three sanctioned survivors as leaks | Retarget: exclude `TUNSTRAP_SESSION_DIR`, `TUNSTRAP_PID`, `TUNSTRAP_OUTPUT_FILE` too. |
+| `test_run_env_io.py:173-193` (`test_multi_node_without_output_var_is_exit_1`) | Asserts exit 1 + `MultiNodeEnvUnsupported`, `not session_dir.exists()` | **Retarget completely — the exact behaviour this task inverts.** Rename to `test_multi_node_without_output_var_now_succeeds`; assert exit 0, no `MultiNodeEnvUnsupported` anywhere in stderr (stderr may be empty), teardown ran. Materialization *content* is not re-verified here — that is `test_cli_run_materialize.py`'s job; this test's remaining job is confirming the real console script allows the case. |
+| `test_cli_modes.py:111-138` | `start --output env`'s `TUNSTRAP_WEB_PORT`/`TUNSTRAP_WEB_ENDPOINT`; `run`'s child probe reading `os.environ['TUNSTRAP_WEB_PORT']` directly | Retarget both tests in this range: the `start --output env` test asserts `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_OUTPUT_FILE` present, `TUNSTRAP_WEB_PORT`/`_ENDPOINT` absent, and derives the port via `json.load(open(env["TUNSTRAP_OUTPUT_FILE"]))["nodes"][...]["ports"]["web"]`; the `run` child probe (inline Python string) rewrites to read `TUNSTRAP_OUTPUT_FILE` the same way. |
+
+#### Blast-radius table — e2e tier + shipped artifacts
+
+In scope, not deferrable: the failure mode is **silent** — `try()` around
+`jsondecode` swallows a shape mismatch into an empty `config_path`, and the
+resulting error is a confusing provider message, not an obvious test failure.
+**The edits themselves land in Task 6** (same textual migration as the recipe,
+and the recipe↔module drift guard requires both to move together); they are
+enumerated here because they are this task's blast radius.
+
+| File:line | Old shape/symbol | Disposition |
+|---|---|---|
+| `tests/e2e/module/main.tf:27-28` | `try(jsondecode(var.tunstrap), { connections = {} })`; `local.tunnel.connections.node.kube_targets.k3s.path` | Retarget: `{ nodes = {} }`; `local.tunnel.nodes.node.kube.k3s.path`. Task 6. |
+| `docs/recipe_terragrunt.md:287-288` | Same `tunnel`/`kubepath` locals, mirroring `main.tf` | Retarget identically. Task 6. |
+| `docs/recipe_terragrunt.md:~329` | Prose: "`path` comes from... `connections.*.kube_targets.*.path`" | Retarget prose to `nodes.*.kube.*.path`. Task 6. |
+| `docs/recipe_terragrunt.md:~407` | Prose: "the module picks the node out of `connections[]`" | Retarget to `nodes[]`; also correct the surrounding paragraph's claim that multi-node suppresses the scalar/`KUBECONFIG` channel — the kube channel is unconditional and the "TUNSTRAP_* env... not injected" framing is stale. Task 6. |
+| `docs/recipe_terragrunt.md:~509` | "What is proven" section: `--output-var` → `TF_VAR_tunstrap` → `try(jsondecode(...))` → `config_path` chain | Mechanism description stays accurate; **verify only** once the two locals above change. |
+| `tests/e2e/rig.py:171` | Docstring: "`module/main.tf` decodes `connections.node.kube_targets.k3s.path`" | Retarget docstring text to `nodes.node.kube.k3s.path`. |
+| `tests/e2e/test_tofu_providers.py:154` | `envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` | Retarget to `envelope["nodes"]["node"]["kube"]["k3s"]["path"]`. |
+| `tests/e2e/test_tofu_providers.py:251-254` | Fake envelope literal `{"connections": {"node": {"ports": {}, "kube_targets": {"k3s": {...}}}}}` | Retarget to `{"nodes": {"node": {"ports": {}, "kube": {"k3s": {"path": ..., "context": ..., "endpoint": ...}}}}}` — align field names with `UnifiedKubeRef`, dropping any field beyond `path`/`context`/`endpoint` the old literal carried. |
+| `tests/e2e/test_terragrunt_apply.py:339,425` | `envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` (apply and tunnelled-output cases) | Retarget both to `envelope["nodes"]["node"]["kube"]["k3s"]["path"]`. |
+| `tests/e2e/test_rig.py:278` | `envelope["connections"]["node"]["kube_targets"]["k3s"]` | **Out of scope, stated explicitly, not silently skipped:** this reads `tunstrap start`'s **raw stdout JSON** (`OutputSchema.model_dump_json()`-shaped), not the `--output-var`/materialized unified channel. Scope here is `run`'s consumer-facing channels plus `start --output env`; `start`'s default/`--output json` stdout is a separate contract for session-management tooling and is deliberately untouched. If a reviewer wants it unified too, that is a new decision. |
+| `tests/e2e/test_recipe_terragrunt.py:259,322` | Recipe↔module drift guard (textual block comparison) | **Unaffected in mechanism.** The compared *content* changes automatically once `main.tf` and the recipe both move to the `nodes.*` shape. Task 6 must keep it green (run it as part of Task 6, not just Task 7). |
+
+#### `test_cli_run_output_var_projection.py` — dedicated retarget (security-critical)
+
+This file pins the credential-scrubbing property for the projected kube
+reference — it must not be weakened while being reshaped. All four tests
+retarget or delete, **not** left alone:
+
+- `test_output_var_never_carries_kube_private_key_material` — retarget the
+ shape lookup:
+ `json.loads(env["TF_VAR_tunstrap"])["nodes"]["node"]["kube"]["k3s"]`
+ instead of `["connections"]["node"]["kube_targets"]["k3s"]`. The absence
+ assertions (`client_key_data`/`client_certificate_data`/`content_b64` not in
+ `target`) are unaffected in spirit, but the field set is now smaller for a
+ second reason too — see the next test.
+- `test_output_var_keeps_every_field_the_consumer_chain_reads` — the
+ anti-vacuity pair. **The expected dict shrinks further than credential
+ removal alone**: `UnifiedKubeRef` carries exactly `{path, context,
+ endpoint}` — `cluster_name`, `local_port`, `tls_server_name`, and
+ `certificate_authority_data` (all present in the old `RunKubeTarget`
+ projection, none of them credentials) are **also** gone, because the design
+ narrows to references only. Retarget the expected dict to exactly
+ `{"path": KUBE_PATH, "context": "probe-context", "endpoint":
+ "https://127.0.0.1:41111"}`. This is a real, intentional narrowing beyond
+ the credential fix — call it out in the retargeted test's docstring so a
+ future reader does not mistake it for scope creep.
+- `test_output_var_projection_leaves_the_rest_of_the_envelope_intact` —
+ retarget: `decoded["pid"]` → `decoded["session"]["pid"]`,
+ `decoded["session_dir"]` → `decoded["session"]["session_dir"]`,
+ `decoded["started_at"]` → `decoded["session"]["started_at"]`,
+ `decoded["connections"]["node"]["ports"]` →
+ `decoded["nodes"]["node"]["ports"]` — **and note the value shape changed
+ too**: `{"db": 5432}` (int) becomes `{"db": "127.0.0.1:5432"}` (string).
+- `test_projection_is_an_allow_list_so_a_new_secret_field_cannot_leak` —
+ **delete, not retarget.** It validated `RunKubeTarget.model_validate`
+ directly, exercising `extra="ignore"`'s fail-closed behaviour against an
+ untrusted dict. `RunKubeTarget` is deleted (Task 4); its replacement,
+ `render_unified_output`, never calls `.model_validate()` on untrusted kube
+ data at all — it constructs `UnifiedKubeRef(path=..., context=...,
+ endpoint=...)` with three explicit keyword arguments, so a hypothetical
+ field added to `KubeTargetOutput` later cannot leak through without someone
+ editing that constructor call by hand. The allow-list property now holds
+ **by construction**, and
+ `test_output_var_keeps_every_field_the_consumer_chain_reads`'s exact-equality
+ assertion already proves it end-to-end. **Confirm by re-reading
+ `render_unified_output`'s body before deleting** — the property must
+ actually hold, not just be asserted to hold by this note.
+
+#### Fetched-file materialization + the `content_b64` enumeration
+
+**New mechanism, same precedent as kube.** `FetchedFile` (`schemas.py:292-313`)
+gains `path: str | None = None`, mirroring `KubeTargetOutput.path`
+(`schemas.py:317-336`) exactly. Wherever kube materialization currently runs
+daemon/worker-side (the same call site the design doc's "Materialization write
+mechanism" section points at — **confirm the exact function before
+implementing; do not assume it is `manager.py:start_all_and_build_output`
+without checking**), add a parallel step: for each successful `FetchedFile` a
+node's `fetch_files` produced, base64-decode `content_b64` and write the raw
+bytes to `tunnel-data/-` using the **same atomic-replace
+primitive** as `output.json` (temp file + `O_EXCL` + `os.replace`, not
+`_write_file`'s `O_TRUNC`), then set `.path`. A failed fetch (`.error` set)
+materializes nothing. `content_b64` itself is **not** deleted from
+`FetchedFile` — it stays internal plumbing, same as kube's own `content_b64`.
+The projection is not a separate function: it is `render_unified_output`'s
+`fetch_files` construction via `UnifiedFetchRef` (Task 4); this step is what
+makes `.path` non-`None` by the time that function runs.
+
+**Kube-internal `content_b64` hits — unaffected, listed to prove they were
+checked, not missed:** `KubeTargetOutput.content_b64` (`schemas.py:335`) is a
+different field entirely (the patched kubeconfig's own content, unrelated to
+`fetch_files`). Every hit here reads or constructs *that* field:
+`test_kube_run.py:111`, `test_envrender.py:20`, `test_output_kube.py:35`,
+`test_tofu_proxy.py:351`, `test_kube_targets.py:91,147` (integration — reads
+`start`'s raw stdout JSON, already out of scope per the carve-out), and the
+**absence** assertions for kube's own `content_b64` in
+`test_cli_run_output_var.py:256,281` and
+`test_cli_run_output_var_projection.py:10,72,91,190,249` (these already
+correctly assert kube's `content_b64` is *not* in the projected shape —
+nothing to change).
+
+**`fetch_files`-related — in scope, retarget:**
+
+| File:line | Old shape/behaviour | Disposition |
+|---|---|---|
+| `test_manager_fetch.py:91` (`test_fetch_files_results_populate_node_output`) | Docstring "Fetcher results land in `NodeOutput.fetch_files` unchanged"; fixture `FetchedFile(content_b64="YQ==", size=1, sha256="ca97")`, no `path` | **False once materialization runs.** Retarget: assert `out.connections["a"].fetch_files["kubeconfig"].path` is set to the expected `tunnel-data/a-kubeconfig` location and its on-disk bytes match `base64.b64decode("YQ==")`; `content_b64` still present on the object (internal plumbing) but the test's point moves to `path`. Rename to drop "unchanged" from the docstring. |
+| `test_fetcher_unit.py:101,111` | `fetcher.fetch_files()`'s own unit test, asserts `ff.content_b64` set on success | **Unaffected** — the SSH-fetch-to-memory layer, upstream of the new daemon-side materialization step; `fetcher.py` itself is not changed, only its caller gains a step after it. |
+| `test_fetch_files.py:67,119,216` (integration) | `base64.b64decode(ff["content_b64"])` reading the raw `start` stdout envelope | **Unaffected in mechanism** (raw stdout stays the "complete envelope") **but verify against the correct channel**: if any of these three actually asserts against `--output-var`/materialized output rather than raw `start` JSON, that assertion retargets to read `ff["path"]` + a direct file read. Confirm which channel each of the three exercises before deciding no change is needed. |
+| `test_fetch_security.py:49,52,69,87-89` (integration) | Proves fetched `content_b64` "appears on stdout only, never on stderr" — i.e. accepts it riding *some* channel | **Retarget the property proved, not just the assertion syntax.** Rewrite to assert (a) `content_b64`/the raw fetched bytes appear **nowhere** in `TF_VAR_tunstrap`, the materialized `output.json`, stdout, or stderr; (b) the file at the reported `path` exists, is mode `0600`, and its bytes match the source. This is a **stronger** security property than before — call that out in the retargeted docstring. |
+| `test_cli_run_output_var.py:83` (`_RICH_PAYLOAD`) | `"fetch_files": {"hosts": {"content_b64": "aG9zdHM=", "size": 6, "sha256": "ab" * 32}}` | Retarget the fixture to `{"hosts": {"path": "/s/tunnel-data/node-hosts", "size": 6, "sha256": "ab" * 32}}`; any downstream assertion reading `fetch_files.hosts.content_b64` from the decoded var retargets to `.path`. |
+| `test_output_schema.py:25,32,46,63` | `FetchedFile(content_b64=...)` construction, xor-validation tests | **Unaffected** — they test `FetchedFile`'s own `content_b64`/`error` xor, unchanged; only a new optional `path` field is added. Add one new case: `path` defaults to `None`, is not part of the xor, and can be set independently after construction (mirror `KubeTargetOutput.path`'s own coverage if a precedent test exists, rather than inventing a new assertion style). |
+
+**`docs/` tier:**
+
+| File:line | Old shape/behaviour | Disposition |
+|---|---|---|
+| `docs/recipe_terragrunt.md:344` | Kube-drop list: "and **drops** `client_key_data`... `content_b64`... `client_certificate_data`" | **Verify only, no rewrite.** States kube's `content_b64` is dropped from `TF_VAR_tunstrap`'s kube projection — still true. |
+| `docs/recipe_terragrunt.md:361-364` | "`tunstrap start` is not affected: it writes the complete envelope to stdout... without `--materialize` its `content_b64` is the only way to obtain the kubeconfig at all." | **Verify only, no rewrite.** Matches the unchanged scope carve-out: `start`'s raw default JSON stdout is untouched, for kube and `fetch_files` alike. |
+| `docs/recipe_terragrunt.md:366-388` (whole subsection, "### Fetched files are exported verbatim, not projected") | Argues the **opposite** of the shipped behaviour: "Every `fetch_files` entry keeps its `content_b64` whole"; "`FetchedFile` has no `path` (`schemas.py:292`), so dropping `content_b64` would be a silent, unrecoverable breakage"; "tunstrap fetches into the envelope (`content_b64`), not onto disk"; and a false premise that the materialize-then-drop end-state "is recorded in the spec's Out of scope" | **REWRITE — the whole subsection.** Replacement text in Task 6 Step 0b. |
+| `tests/e2e/module/main.tf:13` | Comment: "the kube target's `client_key_data`, `client_certificate_data` and `content_b64` are dropped" | **Unaffected** (kube-only field) — already inside the region Task 6 Step 0 edits for the unrelated `connections.*`→`nodes.*` rename. |
+| Untracked characterization harness | Fetch/kube fixtures using `content_b64` | **Out of scope, stated explicitly.** It is not part of the test suite, shipped code, or consumer documentation. |
+| `docs/specs/2026-05-20-feature-fetch-files-design.md`, `docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`, `docs/specs/2026-08-03-run-env-io-decision-history.md`, `docs/specs/2026-05-30-kube-targets-design.md`, `docs/superpowers/plans/2026-06-25-cli-run-modes.md`, `docs/superpowers/plans/2026-05-30-kube-targets.md` | Pre-#15 design/decision/plan documents for already-shipped tickets (#14 and earlier) | **Out of scope, historical record — cited, never edited.** Editing a completed ticket's own spec to match a later ticket's decision would falsify the record of what that ticket actually shipped. |
+| Untracked superseded owner-tracking design | — | **Out of scope** — historical scratch material, not live. |
+| Untracked issue #15 spike notes | Kube-only `content_b64` hits (patched-kubeconfig content in the collision-test prototype) | **Unaffected** (kube, not `fetch_files`) and a frozen historical spike snapshot. |
+
+**Schema note:** `FetchedFile`'s xor validator (`schemas.py:303-314`) needs no
+new logic for `path` — it is a plain optional field set post-construction by
+the materialization step, the same relationship `KubeTargetOutput.path`
+already has to that model's own required fields. Confirm against the actual
+`KubeTargetOutput` definition before implementing, not assumed from this note.
+
+**`predicted_env_keys`/anti-drift guard: checked, unaffected by the
+fetched-file change.** `TUNSTRAP_OUTPUT_FILE` is one of the three
+unconditional survivors `_build_child_env` injects and `predicted_env_keys`
+reserves for **`run`**, not only for `start --output env`. `fetch_files`'s own
+keys never touched env at all, so the guard's key set is untouched by the
+materialization change — verified, not silently assumed.
+
+#### Anti-drift guard — retargeted, not deleted, and two-part
+
+**The guard is extended, never weakened.** After this task there are still
+**two independent implementations** of "what keys will `run` inject":
+`_build_child_env` (hardcodes `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`/
+`TUNSTRAP_OUTPUT_FILE`, merges `render_kube_env`'s output) and
+`predicted_env_keys` (Task 3's conservative formula). If these two silently
+diverge, the pre-spawn `--output-var` collision check (`_validate_output_var`,
+`cli.py:311-324` — confirm the exact line against the checked-out file)
+under-rejects: a NAME that collides with a key `_build_child_env` actually
+injects would sail through validation and then genuinely collide post-spawn.
+
+The guard is **two independent tests**, because `predicted_env_keys` is
+deliberately conservative rather than exact, so exact equality against the
+actual export cannot hold in general (a schema with one kube target that
+materializes cleanly predicts all three kube names while the actual export has
+only two — correctly unequal):
+
+1. **Formula test** (exact equality, unit-test style — proves the conservative
+ formula itself is implemented correctly): the
+ `test_predicted_env_keys_reserves_all_three_for_one_kube_target` /
+ `..._two_kube_targets_one_node` pair already written in Task 3.
+2. **Safety-envelope test** (subset — proves the conservative reservation
+ still covers whatever *actually* gets injected, even when cardinality
+ shrinks between input and output). A subset check here is *correct*, not a
+ weakening, precisely because it is paired with (1):
+
+```python
+def test_predicted_env_keys_covers_actual_injected_keys_under_cardinality_shrink(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Safety-envelope half of the two-part anti-drift guard: predicted must be
+ a superset of actual, driven by the exact scenario that falsifies a
+ predictor that got the conservatism backwards -- two kube targets
+ DECLARED (one on an optional node that fails), only ONE materializes. A
+ NAME colliding with a key _build_child_env actually injects, but which
+ predicted_env_keys failed to reserve, would sail through the pre-spawn
+ collision check and then genuinely collide post-spawn."""
+ from tunstrap import cli as cli_mod
+ from tunstrap.cli import _build_child_env
+
+ # _build_child_env starts from dict(os.environ) (cli.py:394), so without
+ # isolating it first, `set(actual)` is the whole ambient environment
+ # (PATH, HOME, ...) and any comparison against it is meaningless in any
+ # real process. Isolate BEFORE calling it, not after: subtracting
+ # os.environ back out (`set(actual) - set(os.environ)`) is NOT an
+ # acceptable substitute -- a key that is both inherited AND injected (an
+ # operator-set KUBECONFIG, or a NAME matching --output-var) would be
+ # subtracted away too, silently under-checking exactly the collision
+ # this guard exists to catch.
+ monkeypatch.setattr(cli_mod.os, "environ", {})
+
+ # Input: two kube targets declared, on two nodes -- one optional and about
+ # to fail. predicted_env_keys sees only this schema.
+ schema = InputSchema.model_validate(
+ {
+ "nodes": {
+ "a": {
+ "host": "h1", "user": "u", "ssh_password": "p",
+ "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}},
+ },
+ "b": {
+ "host": "h2", "user": "u", "ssh_password": "p", "required": False,
+ "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}},
+ },
+ }
+ }
+ )
+ # Output: node "b" failed (required: false), only node "a"'s kube target
+ # actually materialized -- output cardinality (1) SHRANK below input
+ # cardinality (2). This is the real _build_child_env sees post-spawn.
+ out = OutputSchema(
+ connections={
+ "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/run/s/tunnel-data/k3s")}),
+ },
+ pid=1, session_dir="/run/s", started_at="now",
+ warnings=[TunnelWarning(node="b", error="optional node refused the forward")],
+ )
+ actual = _build_child_env(out, output_var=None, input_env=None)
+ # Subset, not equality: predicted (conservative, computed from input
+ # cardinality 2) legitimately claims MORE than actual (exact, computed
+ # from output cardinality 1) -- that asymmetry is the whole point.
+ assert set(actual) <= predicted_env_keys(schema)
+ # Anti-vacuity: KUBE_CONFIG_PATHS specifically must be in the prediction
+ # even though it is NOT in the actual export (the >=2 branch never fires
+ # here) -- this is the exact key an exact-cardinality predictor would
+ # have wrongly omitted.
+ assert "KUBE_CONFIG_PATHS" in predicted_env_keys(schema)
+ assert "KUBE_CONFIG_PATHS" not in actual
+```
+
+Add it in `tests/unit/test_envrender.py` (not a new file), here in Task 5,
+alongside `_build_child_env`'s own implementation — it is the piece that needs
+both sides to exist simultaneously.
+
+**Files:**
+- Modify: `tunstrap/cli.py`, `tunstrap/envrender.py` (delete `render_env`),
+ `tunstrap/exceptions.py` (delete `MultiNodeEnvUnsupported`),
+ `tunstrap/schemas.py` (`FetchedFile.path`), the daemon/worker
+ materialization site, optionally `tunstrap/session.py`
+- Test (unit): `tests/unit/test_cli_run_output_var.py`,
+ `test_cli_run_output_var_projection.py`, `test_cli_run_materialize.py`
+ (new), `test_cli_run.py`, `test_cli_run_input_env_scrub.py`,
+ `test_cli_runner.py`, `test_cli_run_postspawn.py`, `test_envrender.py`,
+ `test_exceptions.py`, `test_tofu_proxy.py` (docstrings only),
+ `test_manager_fetch.py`, `test_output_schema.py`
+- Test (integration): `tests/integration/test_run_env_io.py`,
+ `test_cli_modes.py`, `test_fetch_files.py` (verify channel),
+ `test_fetch_security.py`
+- Test/artifact (e2e): enumerated above, edited in Task 6
+
+- [ ] **Step 1: Write failing tests**
+
+New file `tests/unit/test_cli_run_materialize.py`:
+
+```python
+"""run's unified-output materialization: /tunnel-data/output.json.
+
+Validates: run always writes the unified structure to a deterministic path,
+mode 0600, regardless of --output-var or node count; the file's content
+equals render_unified_output's output for the same OutputSchema.
+Code: tunstrap/cli.py (materialization call site)
+Method: CliRunner + spawn_daemon/Popen/_teardown_run monkeypatched, as in
+test_cli_run_output_var.py; read the file back after invoke().
+"""
+
+from __future__ import annotations
+
+import json
+import stat
+from pathlib import Path
+from typing import Any
+
+import pytest
+from click.testing import CliRunner
+
+from tests.unit.conftest import cleaning_teardown
+from tunstrap import cli as cli_mod
+from tunstrap.cli import main
+from tunstrap.envrender import render_unified_output
+from tunstrap.schemas import OutputSchema
+
+pytestmark = pytest.mark.unit
+
+
+def test_run_materializes_output_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A single-node run writes tunnel-data/output.json, mode 0600, matching content."""
+ session_dir = tmp_path / "session"
+ session_dir.mkdir()
+ payload = {
+ "connections": {"h": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}}},
+ "pid": 99, "session_dir": str(session_dir), "started_at": "2026-08-07T00:00:00Z",
+ }
+ monkeypatch.setattr(
+ cli_mod, "spawn_daemon",
+ lambda schema, session_dir=None, *, input_env=None: {"kind": "success", "payload": payload},
+ )
+ monkeypatch.setattr(cli_mod.subprocess, "Popen", _FakePopen)
+ monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown)
+ monkeypatch.setenv("TUNSTRAP_INPUT", json.dumps({"nodes": {"node": {
+ "host": "h", "user": "u", "ssh_password": "p", "remote_targets": {"db": "127.0.0.1:5432"},
+ }}}))
+ result = CliRunner().invoke(
+ main, ["run", "--input-env", "TUNSTRAP_INPUT", "--", "true"]
+ )
+ assert result.exit_code == 0, result.stderr
+ materialized = session_dir / "tunnel-data" / "output.json"
+ assert materialized.exists()
+ assert stat.S_IMODE(materialized.stat().st_mode) == 0o600
+ out = OutputSchema.model_validate(payload)
+ assert json.loads(materialized.read_text()) == render_unified_output(out)
+ assert _FakePopen.last_env is not None
+ assert _FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == str(materialized)
+
+
+class _FakePopen:
+ last_env: dict[str, str] | None = None
+ returncode = 0
+
+ def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None:
+ _FakePopen.last_env = env
+
+ def wait(self) -> int:
+ return 0
+
+ def send_signal(self, _signum: int) -> None:
+ pass
+```
+
+Add to `tests/unit/test_cli_run_output_var.py`:
+
+```python
+def test_multi_node_run_succeeds_without_output_var(
+ monkeypatch: pytest.MonkeyPatch, spawn: list[Any]
+) -> None:
+ """Multi-node input with NO --output-var succeeds: materialization covers
+ multi-node unconditionally, so the opt-in gate has nothing left to force."""
+ survivor_a = {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": _RICH_KUBE}}
+ other_kube = dict(_RICH_KUBE, path="/s/tunnel-data/node-b-k3s")
+ survivor_b = {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": other_kube}}
+ spawn[0](
+ {
+ "kind": "success",
+ "payload": {
+ "connections": {"a": survivor_a, "b": survivor_b},
+ "pid": 99, "session_dir": "/s", "started_at": "2026-08-07T00:00:00Z",
+ },
+ }
+ )
+ monkeypatch.setenv(VAR, _payload({"a": _node(), "b": _node()}))
+ result = CliRunner().invoke(main, ["run", "--input-env", VAR, "--", "true"])
+ assert result.exit_code == 0, result.stderr
+ assert FakePopen.last_env is not None
+ joined = "/s/tunnel-data/node-k3s:/s/tunnel-data/node-b-k3s"
+ assert FakePopen.last_env["KUBECONFIG"] == joined
+ assert FakePopen.last_env["KUBE_CONFIG_PATHS"] == joined
+ assert "KUBE_CONFIG_PATH" not in FakePopen.last_env
+ assert FakePopen.last_env["TUNSTRAP_SESSION_DIR"] == "/s"
+ assert FakePopen.last_env["TUNSTRAP_PID"] == "99"
+ assert FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == "/s/tunnel-data/output.json"
+
+
+def test_suppress_kubeconfig_drops_all_three_kube_env_names(
+ monkeypatch: pytest.MonkeyPatch, spawn: list[Any]
+) -> None:
+ """suppress_kubeconfig (the tunstrap_tofu proxy's guard) must drop
+ KUBE_CONFIG_PATH/_PATHS too, not just KUBECONFIG -- those are the names
+ the providers actually read (ADR entry 7)."""
+ from tunstrap.cli import _build_child_env
+ from tunstrap.schemas import OutputSchema
+
+ out = OutputSchema.model_validate(
+ {
+ "connections": {"h": {"ports": {}, "kube_targets": {"k3s": _RICH_KUBE}}},
+ "pid": 1, "session_dir": "/s", "started_at": "now",
+ }
+ )
+ env = _build_child_env(out, output_var=None, input_env=None, suppress_kubeconfig=True)
+ assert "KUBECONFIG" not in env
+ assert "KUBE_CONFIG_PATH" not in env
+ assert "KUBE_CONFIG_PATHS" not in env
+```
+
+Also add the safety-envelope anti-drift test given in full above, to
+`tests/unit/test_envrender.py`.
+
+- [ ] **Step 2: Retarget every pre-existing test pinning the removed machinery**
+
+**`tests/unit/test_cli_run_output_var.py`** — this file's whole premise (the
+scalar/`--output-var` interaction) partly no longer exists:
+
+- `test_collision_with_injected_scalar_is_usage_error` — pins `--output-var
+ TUNSTRAP_DB_PORT` colliding with an injected scalar. `TUNSTRAP_DB_PORT` is
+ never injected now, so the collision cannot occur. **Delete this test** —
+ there is no equivalent behaviour to assert.
+- `test_non_colliding_tunstrap_prefixed_name_is_accepted` — rename to
+ `test_tunstrap_prefixed_output_var_name_is_accepted` and drop the "only some
+ are protected" framing from the docstring; its only remaining job is
+ confirming `--output-var TUNSTRAP_ANYTHING` is not rejected just for the
+ prefix.
+- `test_multi_node_without_output_var_is_exit_1_pre_spawn` — **delete**;
+ superseded by Step 1's `test_multi_node_run_succeeds_without_output_var`,
+ not retargetable (the assertion is the literal opposite).
+- `test_multi_node_with_output_var_reaches_spawn` — still valid in spirit;
+ update the docstring only (it describes removed `render_env` behaviour). The
+ assertions are unaffected — it never inspects env content.
+- `test_output_var_carries_the_whole_envelope_minus_kube_credentials` —
+ **retarget in place**: rename to
+ `test_output_var_carries_the_unified_structure_minus_kube_credentials`,
+ replace the expected-shape assertions with the unified shape
+ (`nodes.node.ports.db == "127.0.0.1:5432"`, `nodes.node.kube.k3s == {"path":
+ ..., "context": ..., "endpoint": ...}`,
+ `nodes.node.fetch_files.hosts.sha256 == ...`), and keep the
+ credential-absence assertions (`client_certificate_data`/`client_key_data`/
+ `content_b64` must still not appear anywhere in the decoded payload) — that
+ property is unchanged, only the container shape is.
+- `test_single_node_keeps_scalars_alongside_output_var` — **delete**; no
+ scalars survive to keep "alongside" anything except the three survivors,
+ already covered by `test_child_env_without_output_var_is_unchanged`.
+- `test_multi_node_injects_output_var_and_no_scalars` — retarget: update the
+ body to decode `render_unified_output`'s shape (`nodes` keyed by `"a"`/`"b"`)
+ instead of the old `OutputSchema.connections` shape; keep the `leaked`
+ scalar-absence assertion (still a real guard against a regression that
+ reintroduces target-scoped scalars).
+- `test_multi_node_suppresses_scalars_but_exports_kube_channel` — retarget:
+ its `leaked = [...TUNSTRAP_...]; assert leaked == []` assertion no longer
+ describes a real guard (the kube channel is unconditional by construction,
+ so there is nothing left to falsify). Rename to
+ `test_optional_node_failure_does_not_affect_kube_channel_or_unified_output`
+ and rewrite the body to assert: the kube channel still fires for the one
+ surviving connection (`KUBECONFIG`/`KUBE_CONFIG_PATH` present), and the
+ unified structure (if `--output-var` given) reflects only the surviving node
+ (`"b"` absent from `nodes`, its failure visible in `session.warnings`). Drop
+ the `leaked` assertion entirely — asserting the absence of something nothing
+ produces is a tautology, not a guard.
+- `test_child_env_without_output_var_is_unchanged` — retarget: the expected
+ `injected` dict shrinks to exactly `{"TUNSTRAP_SESSION_DIR": "/s",
+ "TUNSTRAP_PID": "99", "TUNSTRAP_OUTPUT_FILE": "/s/tunnel-data/output.json"}`
+ (drop `TUNSTRAP_DB_HOST`/`_PORT`/`_ENDPOINT`; this fixture's node has no
+ kube_targets, so no kube keys either). Docstring: "the three survivors,
+ session metadata only." The existing `injected` filter
+ (`k.startswith(("TUNSTRAP_", "KUBECONFIG"))`) is already broad enough to
+ catch `TUNSTRAP_OUTPUT_FILE` — only the expected dict changes.
+
+**`tests/unit/test_envrender.py`** — delete every `render_env`-specific test
+**by name**: `test_render_ports_and_session`,
+`test_render_kube_sets_kubeconfig`, `test_render_kube_not_materialized_raises`,
+`test_render_requires_single_node_zero`,
+`test_render_requires_single_node_two`. Also delete the now-unused
+module-level `from tunstrap.exceptions import MultiNodeEnvUnsupported`
+(`test_envrender.py:4` — `ruff` F401), plus
+`test_predicted_env_keys_no_kube_omits_kubeconfig` and
+`test_predicted_env_keys_multi_node_is_empty`.
+
+**Do NOT delete `test_predicted_env_keys_matches_render_env`** — retarget it in
+place to `test_predicted_env_keys_covers_actual_injected_keys_under_
+cardinality_shrink` (the safety-envelope half, code given in full above). Two
+independent implementations of the injected-key set still exist; the guard
+still has a job.
+
+Replace the two deleted "shape" tests with:
+
+```python
+def test_predicted_env_keys_is_session_scalars_plus_kube_channel() -> None:
+ """predicted_env_keys collapses to the three survivors + the CONSERVATIVE
+ kube channel (all three names, not just the branch this input's exact
+ declared cardinality would hit) -- there is no other injected key left,
+ and the formula does not vary by exact count."""
+ schema = InputSchema.model_validate(
+ {
+ "nodes": {
+ "a": {
+ "host": "h", "user": "u", "ssh_password": "p",
+ "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}},
+ },
+ "b": {
+ "host": "h2", "user": "u", "ssh_password": "p",
+ "kube_targets": {"k4s": {"kubeconfig_path": "/etc/k4s.yaml"}},
+ },
+ }
+ }
+ )
+ assert predicted_env_keys(schema) == {
+ "TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE",
+ "KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS",
+ }
+
+
+def test_predicted_env_keys_no_kube_is_just_the_three_survivors() -> None:
+ schema = InputSchema.model_validate(
+ {"nodes": {"a": {"host": "h", "user": "u", "ssh_password": "p",
+ "remote_targets": {"db": "127.0.0.1:1"}}}}
+ )
+ assert predicted_env_keys(schema) == {
+ "TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE",
+ }
+```
+
+`format_exports`'s own test (`test_format_exports_quotes_safely`) is
+unaffected — it takes a plain `dict[str, str]`, not an `OutputSchema`.
+
+**`tests/unit/test_exceptions.py`**: delete **all three**
+`MultiNodeEnvUnsupported` cases by name: the subclass check (`:87-90`), the
+exit-code + envelope test (`:94-99`), and the `_EXIT_CODES` table test
+(`:107-114`).
+
+**`tests/unit/test_tofu_proxy.py`**: the two `suppress_kubeconfig`-related
+tests (`test_tunnelled_suppresses_kubeconfig_in_child_env`,
+`test_tunnelled_drops_an_inherited_kubeconfig_in_the_multi_node_case`) keep
+their assertions unchanged (`KUBECONFIG` still must not leak) but their
+docstrings currently frame the pop in single-node-vs-multi-node /
+`inject_scalars` terms — update both to drop that framing entirely; one
+unconditional pop covers every case.
+
+**`tests/unit/test_manager_fetch.py`, `tests/unit/test_output_schema.py`**:
+per the fetched-file table above.
+
+- [ ] **Step 3: Run to verify failure**
+
+`.venv/bin/pytest tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py tests/unit/test_exceptions.py -v`
+
+Expected: FAIL across the board — `render_env`/`MultiNodeEnvUnsupported`/
+`inject_scalars` still exist and behave the old way; `_build_child_env` still
+requires `inject_scalars` and injects only two survivors, not three; no
+materialization call site exists yet; `start --output env` still emits
+per-target scalars.
+
+- [ ] **Step 4: Implement**
+
+`tunstrap/exceptions.py`: delete the `MultiNodeEnvUnsupported` class and its
+`_EXIT_CODES` entry.
+
+`tunstrap/envrender.py`: delete `render_env` in its entirety, and the
+now-unused `from tunstrap.exceptions import MultiNodeEnvUnsupported` import
+(`envrender.py:13`). Rewrite `predicted_env_keys`:
+
+```python
+def predicted_env_keys(schema: InputSchema) -> set[str]:
+ """Env keys ``run`` will inject for this *input* schema, unconditional on
+ node count: the three session scalars, plus -- conservatively, not per the
+ exact _kube_channel_keys(count) branch -- all three kube names whenever
+ any node declares kube_targets at all. Input cardinality can shrink by
+ output time (an optional node/target can fail without failing the run), so
+ predicting the exact branch would under-reserve; see the "Anti-drift
+ guard" section for the cardinality-shrink case this guards against. Used
+ pre-spawn to reject a colliding --output-var NAME before a daemon exists.
+ """
+ keys = {"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE"}
+ if any(node.kube_targets for node in schema.nodes.values()):
+ keys |= {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"}
+ return keys
+```
+
+This keeps Task 3's conservative kube rule verbatim and only drops the scalar
+half's `if len(schema.nodes) == 1:` per-target block. **Do not reintroduce
+`_kube_channel_keys(total_kube)` (the exact per-count branch) here** — that
+would silently make the predictor exact again and reopen the under-reservation
+hole.
+
+`tunstrap/schemas.py`: add `path: str | None = None` to `FetchedFile`.
+
+`tunstrap/session.py`: confirm `SessionDir._write_file`'s exact signature
+(`session.py:132`) before item 4 below. **It is not a drop-in reuse** —
+`_write_file` is mode-fixed-at-creation but not atomic (`O_TRUNC`, no rename
+step), while materialization needs true atomicity too.
+
+**Daemon/worker side:** add the fetched-file materialization step described in
+"Fetched-file materialization" above, at the same site kube materialization
+already runs (confirm the function by reading the code).
+
+`tunstrap/cli.py`:
+
+1. **Remove the `inject_scalars` parameter from all four places that thread
+ it:**
+ - `_build_child_env` (`cli.py:365-372`, parameter declaration) — remove the
+ parameter and its `if inject_scalars:` branch (`cli.py:399`).
+ - `_run_child` (`cli.py:466-474`, parameter; `cli.py:486`, passed through
+ to `_build_child_env`).
+ - `_supervise_child` (`cli.py:513-521`, parameter; `cli.py:543`, passed
+ through to `_run_child`).
+ - `run_command` (`cli.py:648`, `inject_scalars = len(schema.nodes) == 1` —
+ delete the line entirely; `cli.py:702`, the keyword argument passed to
+ `_supervise_child` — delete it from the call).
+
+ Confirm all four sites against the checked-out file rather than trusting
+ these line numbers verbatim — they may have shifted once Tasks 1-4 land.
+2. Remove the `cli.py:640` pre-spawn block:
+ ```python
+ if output_var is None and len(schema.nodes) != 1:
+ raise MultiNodeEnvUnsupported(...)
+ ```
+ entirely — multi-node without `--output-var` is no longer rejected.
+3. Rewrite `_build_child_env`:
+
+```python
+def _build_child_env(
+ output: OutputSchema,
+ *,
+ output_var: str | None,
+ input_env: str | None,
+ suppress_kubeconfig: bool = False,
+) -> dict[str, str]:
+ child_env = dict(os.environ)
+ if input_env is not None:
+ child_env.pop(input_env, None)
+ child_env["TUNSTRAP_SESSION_DIR"] = output.session_dir
+ child_env["TUNSTRAP_PID"] = str(output.pid)
+ child_env["TUNSTRAP_OUTPUT_FILE"] = _materialized_output_path(output.session_dir)
+ child_env.update(render_kube_env(output))
+ if suppress_kubeconfig:
+ for key in ("KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"):
+ child_env.pop(key, None)
+ if output_var is not None:
+ child_env[output_var] = render_output_var(output)
+ return child_env
+
+
+def _materialized_output_path(session_dir: str) -> str:
+ """The deterministic path the materialization writer writes to; shared so
+ _build_child_env's TUNSTRAP_OUTPUT_FILE and the actual writer never
+ independently compute a different path for the same file."""
+ return str(Path(session_dir) / "tunnel-data" / "output.json")
+```
+
+ No branch, no `inject_scalars` parameter anywhere in the call chain.
+4. **Materialization writer — a true atomic replace, not write-then-chmod and
+ not `O_TRUNC` alone.** `SessionDir._write_file`'s real property is
+ **mode-fixed-at-creation** (`session.py:132`, `os.open(path, O_CREAT |
+ O_WRONLY | O_TRUNC, 0o600)`, no separate `chmod`) — **not** atomic:
+ `O_TRUNC` overwrites in place, visible mid-write. `Path.write_text()` +
+ `.chmod(0o600)` is worse still (a real, umask-dependent `0644` window).
+ This write needs *both* mode-fixed-at-creation *and* true atomicity, for
+ three reasons: (1) **torn-read prevention** if the process is killed
+ mid-write — a truncated file at the final path is indistinguishable from a
+ valid short one to a naive reader, while `os.replace` guarantees only a
+ complete old or complete new file is ever observable; (2)
+ **defense-in-depth** against any future change that reintroduces a
+ stable/reusable path; (3) the fetched-file writer shares this exact
+ primitive, so one atomic-replace primitive is reasoned about once, not
+ twice. Use a temp file + rename:
+
+```python
+ materialized_path = _materialized_output_path(output.session_dir)
+ tunnel_data_dir = Path(materialized_path).parent
+ tunnel_data_dir.mkdir(parents=True, exist_ok=True)
+ tmp_path = tunnel_data_dir / f".output.json.{os.getpid()}.tmp"
+ fd = os.open(tmp_path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600)
+ try:
+ os.write(fd, render_output_var(output).encode())
+ finally:
+ os.close(fd)
+ os.replace(tmp_path, materialized_path)
+```
+
+ `O_EXCL` on the temp file guards against a colliding temp name (the mode is
+ already fixed at creation); `os.replace` is the atomic step. If
+ `SessionDir._write_file` can be refactored into something callable without
+ a live `SessionDir` instance (this writer runs in the CLI **parent**
+ process, `run_command`, which holds no `SessionDir` — kube materialization
+ happens daemon/worker-side, inside the process that does own one), factor
+ the primitive above into a small shared helper in `session.py` that both
+ call sites use; otherwise replicate it in `cli.py` as shown. **Do not
+ describe this as "reusing `_write_file`" if the code is not actually
+ shared** — the temp-file + `os.replace` step is new work `_write_file` does
+ not do.
+
+ Place the call in `run_command`'s success path — the same place
+ `_build_child_env` is already called, **inside the `try` that owns
+ teardown** (the "cleanup must own the whole post-spawn window" invariant
+ from `docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md` applies:
+ this write is new work in that protected window) — unconditionally,
+ regardless of `--output-var` and regardless of node count. **Confirm the
+ exact `run_command` call site by reading the checked-out `cli.py`**; line
+ citations for this function have drifted before, so re-resolve rather than
+ trusting one.
+5. **`start_command`'s `--output env` mode** (`cli.py:204-206`,
+ `sys.stdout.write(format_exports(render_env(out)))`) is the **other**
+ caller of `render_env` — deleting the function without touching this call
+ site breaks `start` outright (`NameError`), not just a stale test. `start`
+ also now materializes under `--output env` (only): it already forces
+ `daemon.materialize` there via `cli.py:191`'s
+ `force_materialize=(output_fmt == "env")`, so the kube files already land
+ on disk; extend that to write `output.json` through the same
+ `_materialized_output_path`/atomic-write helper from item 4. Update the
+ branch to build the same three-survivors-plus-kube-channel mapping
+ `_build_child_env` uses:
+ ```python
+ if kind == "success" and output_fmt == "env":
+ out = OutputSchema.model_validate(message["payload"])
+ _write_materialized_output(out) # same helper as item 4
+ env = {
+ "TUNSTRAP_SESSION_DIR": out.session_dir,
+ "TUNSTRAP_PID": str(out.pid),
+ "TUNSTRAP_OUTPUT_FILE": _materialized_output_path(out.session_dir),
+ }
+ env.update(render_kube_env(out))
+ sys.stdout.write(format_exports(env))
+ ```
+ Fix the existing test pinning this mode (`test_cli_runner.py:392`) in place
+ — do not add a new test alongside a stale one.
+
+ **Stdin-mode guard — a real reachable failure, not a theoretical one.**
+ `--output env` forces `daemon.materialize = True` only for **flag mode**
+ (`build_flag_schema`'s `force_materialize=(output_fmt == "env")`,
+ `cli.py:191`); a **stdin**-supplied payload's own `daemon.materialize` is
+ the caller's explicit statement and `_pick_start_input_schema` leaves it
+ alone (`cli.py:160-174`). A stdin payload declaring `kube_targets` with
+ `materialize: false` under `--output env` therefore reaches the now
+ unconditional `render_kube_env(out)` call with `target.path is None`, which
+ raises a bare `ValueError` — an ugly traceback, not a typed error. **Fix
+ before wiring the unconditional call:** **choose (a)** — force
+ `daemon.materialize = True` for the stdin path too when `output_fmt ==
+ "env"`, matching flag mode's own precedent (smallest change, consistent:
+ `--output env` needs materialized kube paths regardless of input channel).
+ The alternative, (b) catching `ValueError` around the `render_kube_env`
+ call and re-raising as a typed `TunstrapError` subclass, is acceptable only
+ if a reviewer specifically wants materialization to stay an operator
+ opt-out even under `--output env`. Add a unit test: stdin payload,
+ `daemon.materialize: false`, `kube_targets` declared, `--output env` →
+ exit 0 with the kube path materialized (option (a)), or a typed error
+ (option (b)) — never a bare `ValueError` traceback.
+
+- [ ] **Step 5: Run to verify pass, then the full unit suite**
+
+`.venv/bin/pytest tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py tests/unit/test_exceptions.py tests/unit/test_tofu_proxy.py tests/unit/test_manager_fetch.py tests/unit/test_output_schema.py -v`
+
+Expected: **all pass, and only after every row of the blast-radius tables has
+actually been applied.** A partial pass with a handful of still-red tests
+means a table row was skipped, not that the row was optional.
+
+`.venv/bin/pytest tests/unit -q`
+
+Expected: full pass. Read the actual count; do not compare it against any
+number recorded in this plan or the spike findings — both predate this task's
+deletions and retargets.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add tunstrap/cli.py tunstrap/envrender.py tunstrap/exceptions.py tunstrap/schemas.py \
+ tunstrap/session.py \
+ tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py \
+ tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py \
+ tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py \
+ tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py \
+ tests/unit/test_exceptions.py tests/unit/test_tofu_proxy.py \
+ tests/unit/test_manager_fetch.py tests/unit/test_output_schema.py
+git commit -m "feat: unified output materialization; remove TUNSTRAP_* scalars, MultiNodeEnvUnsupported, inject_scalars (#15)"
+```
+
+**Note:** include `tunstrap/session.py` only if Step 4 item 4 added a shared
+atomic-write helper there; also `git add` the daemon/worker module that gained
+the fetched-file materialization step.
+
+- [ ] **Step 7: Integration retargets**
+
+The blast-radius table's integration rows are their own step, not folded into
+Task 7's gate pass — they are behavioural retargets (TDD-shaped: they can fail
+against the old code and must pass against the new), not verification only.
+
+**Files:** `tests/integration/test_run_env_io.py`,
+`tests/integration/test_cli_modes.py`, plus the `test_fetch_files.py` /
+`test_fetch_security.py` dispositions from the fetched-file table.
+
+Apply every integration row: `_PROBE_SINGLE`/`_PROBE_MULTI` read
+`TUNSTRAP_OUTPUT_FILE` instead of `TUNSTRAP_WEB_PORT`; the "no scalar leak"
+check excludes the three sanctioned survivors;
+`test_multi_node_without_output_var_is_exit_1` is renamed and inverted to
+`test_multi_node_without_output_var_now_succeeds`; `test_cli_modes.py`'s
+`start --output env` test and `run`'s child probe both retarget to
+`TUNSTRAP_OUTPUT_FILE`. Run (requires Docker, per `tests/README.md`):
+
+```bash
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/integration -m integration -q -k "run_env_io or cli_modes or fetch"
+```
+
+Expected: FAIL before the retargets (old assertions against new behaviour),
+PASS after. Commit:
+
+```bash
+git add tests/integration/test_run_env_io.py tests/integration/test_cli_modes.py \
+ tests/integration/test_fetch_files.py tests/integration/test_fetch_security.py
+git commit -m "test(integration): retarget env-shape assertions for the unified output contract (#15)"
+```
+
+---
+
+### Task 6: Recipe documentation + e2e artifact shape migration
+
+**This task carries the e2e-tier and shipped-recipe rows of Task 5's
+blast-radius tables** — they land here, not in Task 5, because they are the
+same textual shape migration as the new recipe content, and
+`test_recipe_terragrunt.py`'s drift guard requires the recipe and
+`tests/e2e/module/main.tf` to move together or it fails by design.
+
+**Files:**
+- Modify: `docs/recipe_terragrunt.md`, `tests/e2e/module/main.tf`,
+ `tests/e2e/rig.py`, `tests/e2e/test_tofu_providers.py`,
+ `tests/e2e/test_terragrunt_apply.py`
+
+- [ ] **Step 0: Fix the recipe's pre-existing `connections.*` shape (before adding new content)**
+
+The recipe already contains working HCL/prose in the old shape. Fix these **in
+place** before Steps 1-2 add anything new, so the document is never left in a
+self-contradictory state (old shape in one section, new shape in another):
+
+- `docs/recipe_terragrunt.md:287-288` — the `tunnel`/`kubepath` locals:
+ `try(jsondecode(var.tunstrap), { connections = {} })` →
+ `try(jsondecode(var.tunstrap), { nodes = {} })`;
+ `local.tunnel.connections.node.kube_targets.k3s.path` →
+ `local.tunnel.nodes.node.kube.k3s.path`.
+- `docs/recipe_terragrunt.md:~329` — prose point 3, "`path` comes from the
+ materialized file... `connections.*.kube_targets.*.path`" → `nodes.*.kube.*.path`.
+- `docs/recipe_terragrunt.md:~407` — "The input variable is scrubbed" section:
+ "the module picks the node out of `connections[]`" → `nodes[]`;
+ also correct the surrounding paragraph's claim that multi-node input
+ suppresses the scalar/`KUBECONFIG` channel entirely — the kube channel is
+ unconditional now; only the `TUNSTRAP__*` scalars, which no longer
+ exist as a concept, were ever suppressed for multi-node.
+- `docs/recipe_terragrunt.md:~509` — "What is proven" section: **verify only**,
+ no shape-specific text to change (the `--output-var` → `TF_VAR_tunstrap` →
+ `jsondecode` → `config_path` chain description stays accurate once the two
+ locals above change).
+
+**`tests/e2e/module/main.tf:27-28`** — the exact chain the e2e tier proves,
+mirroring the recipe: `try(jsondecode(var.tunstrap), { connections = {} })` →
+`{ nodes = {} }`; `local.tunnel.connections.node.kube_targets.k3s.path` →
+`local.tunnel.nodes.node.kube.k3s.path`. Update the module's own header
+comment (`main.tf:1-9`, "The exact chain this tier exists to prove") to match.
+
+**`tests/e2e/rig.py:171`** — docstring: "`module/main.tf` decodes
+`connections.node.kube_targets.k3s.path`" → `nodes.node.kube.k3s.path`.
+
+**`tests/e2e/test_tofu_providers.py:154`** —
+`envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` →
+`envelope["nodes"]["node"]["kube"]["k3s"]["path"]`.
+
+**`tests/e2e/test_tofu_providers.py:251-254`** — the fake envelope dict literal
+(`"connections": {"node": {"ports": {}, "kube_targets": {"k3s": {...}}}}`) →
+`{"nodes": {"node": {"ports": {}, "kube": {"k3s": {"path": ..., "context":
+..., "endpoint": ...}}}}}`, aligning field names with `UnifiedKubeRef` (drop
+any field beyond `path`/`context`/`endpoint` the old literal carried — this
+fixture only needs enough to drive its dead-cluster negative-control
+scenario).
+
+**`tests/e2e/test_terragrunt_apply.py:339,425`** —
+`envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` (apply and
+tunnelled-output cases) → `envelope["nodes"]["node"]["kube"]["k3s"]["path"]`
+at both sites.
+
+**Not touched, disposition recorded** (restated here where a reader would
+otherwise expect to find them fixed): `tests/e2e/test_rig.py:278` reads
+`start`'s raw stdout JSON, out of scope; `tests/e2e/test_recipe_terragrunt.py`'s
+drift guard needs no code change — its compared content updates automatically
+once the steps above land.
+
+- [ ] **Step 0b: Rewrite `docs/recipe_terragrunt.md:366-388` — the "Fetched files are exported verbatim, not projected" subsection**
+
+This shipped subsection currently argues the **opposite** of the shipped
+behaviour (fetch content stays whole in the envelope, `FetchedFile` has no
+`path`, dropping `content_b64` would be "a silent, unrecoverable breakage").
+Fix in place, same "before Steps 1-2 add anything new" discipline as Step 0.
+Replace the entire subsection (heading through the final paragraph ending
+"...is recorded in the spec's 'Out of scope'.") with:
+
+> ### Fetched files are materialized, not carried in the envelope
+>
+> The projection above (kube) and this one (`fetch_files`) follow the same
+> rule: `run` materializes content to disk under the session dir's
+> `tunnel-data/`, mode `0600`, and the consumer-facing envelope carries only a
+> reference to it. Each `fetch_files` entry becomes `{path, size, sha256}` on
+> success, `{error}` on failure — never `content_b64`.
+>
+> `FetchedFile` **has a `path`** (`schemas.py`, extended for this ticket), so
+> the lossless on-disk alternative exists, the same way it already existed for
+> kube.
+>
+> **The plan-file-persistence risk is resolved as a class, not documented
+> around**: since fetched content never enters `TF_VAR_tunstrap` or the
+> materialized file at all, `--fetch`ing a secret cannot land it in a saved
+> Terraform plan file through this channel. Read the file directly at
+> `fetch_files..path` if you need its contents.
+
+Also check the one adjacent sentence this rewrite does not itself replace: the
+"One other free-form string rides this channel unprojected: `warnings[*].error`"
+paragraph immediately after (line ~390) stays accurate as written —
+`warnings[*].error` is unrelated to `fetch_files` — but confirm after the edit
+that "One other" still reads correctly given the preceding subsection no
+longer describes `fetch_files` as unprojected. Reword the transition if it no
+longer parses; do not leave a dangling "other."
+
+Verify lines 344 and 361-364 (the kube-drop list and the `start` carve-out,
+immediately above this subsection) need **no** edit — both state kube-specific
+`content_b64` facts that remain true.
+
+Run (requires `kind`/`tofu`/`kubectl`/Docker, per `tests/README.md` — optional
+locally, mandatory before merge per Task 7 Step 4):
+
+```bash
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e -m e2e -q
+```
+
+Commit this shape-migration half separately from the new recipe content below,
+so a reviewer sees "shape rename, no behaviour change" and "new recipe
+content" as two independently reviewable diffs:
+
+```bash
+git add tests/e2e/module/main.tf tests/e2e/rig.py tests/e2e/test_tofu_providers.py \
+ tests/e2e/test_terragrunt_apply.py
+git commit -m "test(e2e): retarget to the unified output nodes.*.kube.*.path shape (#15)"
+```
+
+- [ ] **Step 1: Add Mode A — env-native kube**
+
+Add a new section (placement: after the existing provider-config example, so
+it reads as "and here is the identity-delivery contract that example depends
+on") titled around **Mode A: env-native kube (satisfies the ticket's strict
+"nothing live enters Terraform" contract)**:
+
+1. `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from tunstrap's own process
+ environment (no `var.`-bound value, no file read in HCL at all for kube)
+ **plus a literal `config_context = "tunstrap--"` per provider
+ alias** — a **two-alias worked HCL example**, citing findings #3 and #5 by
+ number:
+ ```hcl
+ provider "kubernetes" {
+ alias = "node1_k3s"
+ config_context = "tunstrap-node1-k3s" # literal -- never derived from var.tunstrap
+ }
+ provider "kubernetes" {
+ alias = "node2_k3s"
+ config_context = "tunstrap-node2-k3s"
+ }
+ ```
+2. Explicit warning: never derive `config_context`'s value from `var.tunstrap`
+ or any decoded data — literal only, matching the deterministic naming
+ scheme exactly.
+3. A short "measured facts a consumer needs" list, restated (not re-derived)
+ from **all six** of the ticket's findings plus this design's provider
+ findings — all six, do not miscount:
+ - **#1** — provider configuration **is** re-evaluated at apply.
+ - **#2** — outputs **freeze silently** — the worst failure mode, name it as
+ such.
+ - **#3** — per-alias `config_context` works with an env-supplied kubeconfig
+ path (Mode A's own basis, shown in item 1's example).
+ - **#4** — plan-safe end to end, measured live: plan with one set of ports,
+ mutate only the kubeconfig, apply the *saved* plan → the alias uses the
+ mutated value, zero plan-variable mismatch.
+ - **#5** — `KUBE_CONFIG_PATHS` is colon-separated (comma silently falls
+ back to `localhost:80`).
+ - **#6** — a live value bound to a `var.` **does** trip "Mismatch between
+ input and plan variable value" on a saved plan (Mode B's one-shot rule
+ rests on this).
+ - A live value bound to a **resource attribute** (not a provider config
+ block) produces `Error: Provider produced inconsistent final plan` — cite
+ the committed provider-precedence spec's Q3 result,
+ and show provider-block placement as the only supported shape in both
+ Mode A and Mode B.
+4. A one-line pointer to the deterministic naming scheme
+ (`tunstrap--`) and why it matters for anyone piping the
+ materialized kubeconfig into `kubectl --context` directly instead of
+ through a provider.
+
+- [ ] **Step 2: Add Mode B — unified-file convenience**
+
+Immediately after Step 1's section (same document — a real consumer may use
+Mode A for kube and Mode B for ports in the same module), add a section titled
+around **Mode B: unified-file convenience (ports + kube references; does NOT
+satisfy the ticket's strict contract — state this plainly)**. **No literal,
+operator-pinned path and no `var.`-derived locator anywhere in this section:**
+
+5. **The shape**, with a worked HCL example using the env-carried
+ `TUNSTRAP_OUTPUT_FILE` locator via Terragrunt's `get_env(...)` — no
+ `--session-dir` precondition and no operator-agreed path, because the
+ session dir is ephemeral unconditionally:
+ ```hcl
+ locals {
+ tunnel = try(
+ jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))),
+ { nodes = {} },
+ )
+ }
+
+ provider "kubernetes" {
+ config_path = local.tunnel.nodes.node1.kube.k3s.path
+ }
+ ```
+ read directly inside the `locals` block that feeds the provider config —
+ never through an `output`, per finding #2.
+6. **Ports lose their integer form** (`"host:port"` string) — show the
+ extraction idiom explicitly:
+ ```hcl
+ locals {
+ service1_port = split(":", local.tunnel.nodes.node1.ports.service1)[1]
+ }
+ ```
+7. **The stability contract**, restated plainly and matching the design doc's
+ "Stability contract" word-for-word on the load-bearing claims: **both** Mode
+ B forms — item 5's `TUNSTRAP_OUTPUT_FILE` form and the `--output-var`
+ (`var.tunstrap`) form — are **one-shot `plan && apply` only**, with no
+ saved-plan reuse across a tunstrap restart for either and no locator
+ exemption of any kind (the check compares the variable's whole value; the
+ file itself is deleted at teardown alongside the rest of `tunnel-data/`).
+ State it as plainly as the design doc does: *"Neither Mode B form survives
+ a tunstrap restart. If you need a saved plan to apply cleanly against fresh
+ ports or fetched-file content, re-run plan in the same tunstrap
+ invocation."* Cite findings #1, #2 and #6 by number.
+8. **The `jsondecode`-not-JavaScript note**, one sentence: consumption is via
+ HCL's `jsondecode`; there is no JS runtime anywhere in this stack (ADR
+ entry 12).
+9. **Fetched files:** state *"Fetched file content never enters a Terraform
+ variable or plan file — only its path, size, and checksum do. Read the file
+ itself at `fetch_files..path` if you need its contents."* Do not
+ carry any warning framed around fetched content riding this channel — it
+ does not.
+
+Match the existing file's structure (numbered/lettered subsections, HCL code
+fences, "Measured Terragrunt facts"-style attribution footers) — read the
+file's current shape before writing; do not introduce a new prose style.
+
+- [ ] **Step 3: Cross-check against the artifacts, the design doc, and the drift guard**
+
+Confirm every measured fact restated in both new sections matches
+the committed provider-precedence spec, the ticket's own
+six findings, and the design doc's "Stability contract" subsection verbatim —
+no rewording that could drift from the source transcripts or introduce a
+second, subtly different phrasing of the same rule. This is a manual
+read-through, not a test.
+
+Then run the recipe↔module drift guard, which must stay green through both
+Step 0's shape migration and this step's new content (it fails loudly, by
+design, if the two documents disagree on a shared HCL block):
+
+```bash
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e/test_recipe_terragrunt.py -m e2e -q
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/recipe_terragrunt.md
+git commit -m "docs(recipe): kubeconfig-as-identity delivery + unified output + stability contract (#15)"
+```
+
+---
+
+### Task 7: Full gate pass
+
+**Files:** none (verification only).
+
+- [ ] **Step 1: Style/type/lint gates**
+
+```bash
+.venv/bin/black --check .
+.venv/bin/ruff format --check .
+.venv/bin/ruff check .
+.venv/bin/pylint tunstrap/
+.venv/bin/vulture tunstrap/
+.venv/bin/mypy --strict tunstrap
+```
+
+Expected: all clean. `vulture` has no whitelist file to update
+(`vulture_whitelist.py` was removed; `min_confidence = 80` in
+`pyproject.toml`). If `rename_identities`/`render_kube_env`/
+`render_unified_output` get flagged as unused, a call site is missing — that
+is not a case for a suppression. Conversely, if `RunKubeTarget`, `render_env`,
+or `MultiNodeEnvUnsupported` are still importable from anywhere,
+`vulture`/`ruff` catching them is the signal Task 5's deletions were
+incomplete. `pylint`'s `fail-under = 9.0` gate applies to the whole
+`tunstrap/` package score, not per-file.
+
+- [ ] **Step 2: Unit suite**
+
+```bash
+.venv/bin/pytest tests/unit -q
+```
+
+Expected: full pass. **Do not compare the count against any number recorded in
+this plan or the spike findings** — this work deletes a meaningful number of
+pre-existing tests while adding others. Run it and read the real number.
+
+- [ ] **Step 3: Integration suite**
+
+```bash
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/integration -m integration -q
+```
+
+Expected: full pass. This tier **does** change: Task 5 Step 7 retargets
+`test_run_env_io.py`, `test_cli_modes.py`, and the fetch tests for the same
+shape/scalar removal as the unit tier, and this is where those retargets are
+proven against the *real* console script and a real docker rig rather than
+`CliRunner`. If this step is reached with those retargets not yet landed it
+will fail, correctly — that failure is Task 5 Step 7 being incomplete, not a
+flake to route around.
+
+- [ ] **Step 4: e2e suite**
+
+This tier **does** change too: Task 6 moves `tests/e2e/module/main.tf`,
+`rig.py`, `test_tofu_providers.py`, and `test_terragrunt_apply.py` to the
+`nodes.*.kube.*.path` shape — real code changes this tier must pass against,
+not a no-op regression check.
+
+```bash
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e -m e2e -q
+```
+
+Expected: full pass, including `test_recipe_terragrunt.py`'s drift guard
+(already run once in Task 6 Step 3; the full tier here is the final
+confirmation nothing else regressed).
+
+**Separately, and optional:** the design doc's "`e2e` coverage — optional,
+with rationale" describes a *different* piece of work — an e2e-level collision
+test proving the kube identity rename (rewriting two kind kubeconfigs to a
+shared identity before feeding them to `tunstrap start`/`run`). No task here
+adds it by default, because Task 2's unit-level regression test already
+exercises that defect precisely. If a reviewer wants it, it is an extension of
+Task 2, not part of this step. Do not conflate the two: Task 6's e2e shape
+migration is mandatory and verified by this step; the collision-specific e2e
+coverage is optional.
+
+- [ ] **Step 5: Final commit / PR**
+
+No further commit needed if Tasks 1-6 committed cleanly and the gates pass on
+the resulting tree. Open or update PR #13 against `feature/run-env-io` per the
+ticket's stated target; **do not merge** (org rule — curate and validate,
+leave the merge decision to the human reviewer).
+
+---
+
+## Coverage checklist
+
+**Kube part (Tasks 1-3):**
+
+| Requirement | Where |
+|---|---|
+| Ticket work item 1 — patch identity names (cluster + user + context) | Task 1 |
+| Rename scope: the active current-context triple only (ADR entry 5) | Task 1 (`rename_identities`, "ignored entries" test) |
+| Every reference to the renamed cluster/user swept, incl. non-current contexts | Task 1 (shared-reference test + implementation sweep) |
+| Naming scheme `tunstrap--