From 08d346dfae72d2bef3fac373b8c7be9ec55b5952 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:21:48 +0200 Subject: [PATCH] Map the cut that moves binding secrets out of every printable surface Operator: "do map the cut that'll ensure it doesn't happen again". Q3 A is ruled (rotate before prod); Q1 A and Q2 A are Self defaults. Co-Authored-By: Claude Opus 5.5 --- docs/secret-files-cut.md | 425 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 docs/secret-files-cut.md diff --git a/docs/secret-files-cut.md b/docs/secret-files-cut.md new file mode 100644 index 0000000..5dbb1c2 --- /dev/null +++ b/docs/secret-files-cut.md @@ -0,0 +1,425 @@ +# Binding secrets out of every printable surface: cut map + +Status: cut map, Imagination pass 1 (Opus), 2026-09-30. Nothing has landed. +There is no separate target document; the invariant in section 1 is the end state. +Anchors are against these HEADs: +- Idunn `46d9c2f` +- gamecult-ops `62e3f05` +- Heimdall `e8e2832` +- StreamPixels `a49f7d8` (reference pattern only) + +Incident: during overnight work an agent printed Heimdall's deployment-binding +secrets into a transcript. The operator's words: "I'll rotate before prod, not too +concerned about Anthropic having my oauth; do map the cut that'll ensure it doesn't +happen again". + +**Rulings and defaults, 2026-09-30 (Self).** +- **Q3 A is the operator's ruling:** "I'll rotate before prod". Cuts 1-4 land with the current values; rotation + overwrites the credential files later. The token-encryption and signing-key sub-question waits for rotation time. +- **Q2 A, Self's default:** the plaintext fallback is a named shim, deleted in Cut 5. It is not a product decision; + the operator may overrule it. +- **Q1 A, Self's default:** no schema change now; B is recorded as a follow-up if a second target repeats + Heimdall's mistake. The operator may overrule it. + +Open: +- Cut 0 capture; +- whether a re-deploy of an unchanged revision reloads a rotated credential (not yet + established; see Cut 3). + +## 1. The invariant, and why a "don't print" rule is the wrong cut + +**Secret material exists only in files whose sole content is one secret.** Those +files are root-owned, mode 0400, and delivered by systemd `LoadCredential`. Nothing +multi-purpose ever holds a secret value: +- no binding, template, recipe, unit or transient unit; +- no plan blob, log, status line or README step. + +If that holds, every existing printer becomes harmless. `cat` of a binding, `idunn +validate`, a journald tail, `systemctl cat`, `idunn status` and a plan decode all show +paths. No agent has to remember anything. + +What was missing, such that printing the secret made sense to the agent: +- The gamecult-ops Heimdall runbook ordered it. `runbooks/heimdall-discord-launch.md:142-150` + says to reconstruct the missing template from the installed binding with + `ssh ygg 'sudo cat /etc/gamecult/idunn/bindings/heimdall.toml'`. +- The installed binding carried secret values under `[workload.environment]`. +- Heimdall's recipe declares those values as plain environment. Its admit contract + therefore makes the binding the only place they can live. + +The agent followed a runbook against a file that should never have held a secret. +The cut removes the secret from the file, not the reading. + +The substrate already exists; it is not an invention of this map: +- Idunn's `[workload.secret_files]` (`src/deployment.rs:607`) lowers to + `--property=LoadCredential=NAME:path` (`src/drivers.rs:3707-3718`). +- It sets `NAME=/run/credentials//NAME` in the environment + (`src/drivers.rs:3311-3336`). +- It admits only root:root 0400 single-link sources in a root-owned directory + (`src/drivers.rs:6966-7000`). +- Idunn never opens a service credential for reading; it only stats it. +- StreamPixels and Ghostlight already use this through `*_FILE` names + (`StreamPixels apps/service/src/security/environment-secret.ts`, + `deployment/idunn/service.toml:130-142`). + +Heimdall is the target that never moved. + +### Leak paths found in the Body + +| # | Path | Evidence | After the cut | +|---|---|---|---| +| P1 | Runbook tells an agent to `sudo cat` the installed binding | gamecult-ops `runbooks/heimdall-discord-launch.md:147` | step deleted (Cut 4); the file holds only paths anyway | +| P2 | Binding install README handles the DB URL in an agent shell (`export DBURL=$(sudo grep … service.env)`, `perl` substitution) | gamecult-ops `deployment/idunn-bindings/README.md:15-30` | directory deleted (Cut 4) | +| P3 | Transient unit carries every workload env value via `--setenv`. That lands in `/run/systemd/transient/.service`, readable through `systemctl cat` and `systemctl show -p Environment` without privilege. | Idunn `src/drivers.rs:3704-3706` | only non-secret values and credential paths | +| P4 | `/proc//environ` of the workload (root-readable; Idunn itself reads it and keeps only a hash, `drivers.rs:3116`, `:3278`) | Idunn `src/drivers.rs:3116` | paths only | +| P5 | Every deployment plan persists the full binding text in `binding_blob`. It lands in `control.cc`, history and the nightly state backup. | Idunn `src/deployment_plan.rs:562`, `:710`; gamecult-ops `runbooks/gamecult-state-backup.md:38-39` | new plans hold paths; **old plans keep old values forever** → rotation (Q3) | +| P6 | TOML errors quote the offending source line verbatim. **CONFIRMED by probe** on `toml 0.8.23`: an invalid escape, a wrong type, an unknown field and an unterminated string each echoed the full `KEY = "value"` line. They reach journald through `load_bindings` (`control_plane.rs:3883` → `eprintln` at `:4597`, `:5350`) and the terminal through `idunn validate` (`:2475`). `scripts/install-idunn-yggdrasil-release.sh:38` tails that journal into the operator's terminal. | Probe: `toml::from_str` with four malformed lines, `e.to_string()` contained the canary in all four | lines hold paths only | +| P7 | `idunn status --command` prints `last_error` verbatim | Idunn `src/control_plane.rs:4131-4133` | nothing secret can be in an error | +| P8 | Legacy pre-Idunn Heimdall. `systemd/heimdall.service:14` reads `EnvironmentFile=/srv/heimdall/env/service.env`. `scripts/deploy-heimdall.sh:182` leaves `.service.env.backup.*` copies. `scripts/check-heimdall.sh:17-20` and `scripts/provision-ghostlight-heimdall-session-yggdrasil.sh:16-19` `source` it. | gamecult-ops | retired in Cut 5 | +| P9 | Two contradictory binding directories. `deployment/idunn-bindings/` says install 0640 root:idunn. `idunn/yggdrasil/bindings/README.md:24-27` says Heimdall is "intentionally absent" (stale). `scripts/idunn/idunn-deployment-targets.ps1:194` repeats it. | gamecult-ops | one directory (Cut 4) | + +Out of scope, recorded: +- Docker runner `[runners.*.environment]` values reach `docker run -e` and `docker + inspect`. +- No current template puts a secret there, and runner `secret_files` already mounts + files (`drivers.rs:2018-2025`). + +## 2. Identity, lifecycle, authority (per persistent kind) + +| Kind | What names it | Life over time | Who decides | +|---|---|---|---| +| Heimdall secret value | credential file `/etc/gamecult/heimdall/credentials/`. Its binding key is the `*_FILE` env name. The directory convention follows `streampixels-service.toml.in [workload.secret_files]`. | Created once by Cut 3. Overwritten in place on rotation (Q3). Never copied elsewhere. | operator (value); ops template (path) | +| Heimdall binding | target `heimdall` | rendered from `idunn/yggdrasil/bindings/heimdall.toml.in`, installed by `install-idunn-yggdrasil-release.sh`; hand edits on the host are forbidden writers | gamecult-ops template | +| Recipe input class (secret vs plain) | env name in Heimdall `deployment/idunn/recipe.toml` | changes only by a reviewed Heimdall commit | Heimdall repo | +| Plan `binding_blob` | plan id (content hash) | immutable history; never rewritten (rewriting breaks `plan_id`, `deployment_plan.rs:662-668`) | Idunn | +| Legacy `service.env` + backups | path under `/srv/heimdall/env` | read-only after Cut 3; shredded in Cut 5 | operator | + +**Constraint that shapes every Idunn-side option.** An admitted generation's +stored plan is re-admitted under the *current* rules on continuity +(`control_plane.rs:6871`, and the other `parsed_inputs()` sites). Any +tightening of `admit()` that rejects an already-stored (recipe, binding) pair +takes daemon survival away from that target. New admit rules must be vacuous +for recipes that predate them. + +## 3. Authority map + +- **Owner of secret material:** the credential file. Nothing else owns a copy. +- **Owner of "is this input secret":** the program's own recipe. It declares secret + inputs only as `*_FILE` names; it declares no plaintext secret name. Idunn + enforces the rest with an existing rule: a binding may bind only declared + names (`src/deployment.rs:1614-1617`). +- **Owner of the binding's shape:** the gamecult-ops template. The installed file is + derived from it. +- **Inputs:** + - Heimdall reads `*_FILE` paths from its environment and the bytes from those + files. + - Idunn reads binding paths and stats the files. +- **Outputs:** systemd credentials under `/run/credentials//`. +- **Derived state:** + - The binding's `[workload.secret_files]` holds paths only. + - The transient unit's `LoadCredential=` and `Environment=` hold paths only. + - The plan blob holds paths only. +- **Forbidden holders of a secret value:** + - `[workload.environment]`; + - any `*.toml.in` or template; + - recipe `required_environment` and `optional_environment`; + - READMEs and runbooks; + - `/srv/heimdall/env/service.env` after Cut 5; + - shell variables in any committed ops script. +- **Forbidden printers:** none needed. A printer of any artifact above prints no + secret. The only way to see a secret is `sudo cat` of a single-purpose + credential file, and that is a deliberate act. +- **Shared paths:** the same `readSecretInput` reader serves legacy start, Idunn + start and tests (Cut 1). The same `admit()` serves `idunn validate`, binding + load and continuity re-admit. +- **Deletion line:** + - Heimdall's plaintext secret names in the recipe (Cut 1). + - `deployment/idunn-bindings/` and the runbook `sudo cat` step (Cut 4). + - The plaintext fallback shim, the legacy unit, `service.env`, the legacy deploy + and check scripts, and the transitional copy script (Cut 5). + +## 4. Cuts + +### Cut 0. Capture (read-only, Yggdrasil; no edits) + +- **Rule for every command:** print key names, hashes or booleans, never values. +- **Never read** `/var/lib/gamecult/**/*.cc`. +- **Key names of the installed binding.** Pipe the binding through this extractor. + It prints section headers and `key = ` names. It refuses base64-looking + continuation lines and counts them instead. It was tested against the ops + template and a multiline PEM: + ``` + sudo awk ' + /^[[:space:]]*\[[^=]*\][[:space:]]*$/ { print; next } + /^[[:space:]]*(#|$)/ { next } + match($0, /^[[:space:]]*[A-Za-z_][A-Za-z0-9_-]*[[:space:]]+=[[:space:]]/) { + k = substr($0, 1, RLENGTH); sub(/[[:space:]]+=[[:space:]]$/, "", k); gsub(/^[[:space:]]+/, "", k) + if (length(k) <= 64) { print " " k; next } } + { other++ } END { print "unclassified-lines " other+0 }' /etc/gamecult/idunn/bindings/heimdall.toml + ``` +- **Key names of the legacy environment file:** + `sudo grep -o '^[A-Za-z_][A-Za-z0-9_]*=' /srv/heimdall/env/service.env`. + Count its siblings with + `sudo ls /srv/heimdall/env | grep -c 'service.env.backup'`. +- **Where each value comes from.** For every secret-class name present in both + sources, print only whether the two values are equal: `sha256` each value + inside one root shell, and emit `NAME equal|differ`. This decides whether + Cut 3 copies from `service.env` or from the binding. +- **What runs Heimdall now:** + `systemctl is-active heimdall.service`, plus the names of the + `idunn-heimdall-*` units. Inventory (`gamecult-ops inventory.md:632-634`) says + the legacy unit runs Heimdall and that Idunn has never admitted a Heimdall + generation. That claim is unverified. +- **Credential-source preconditions:** `stat -c '%U:%G %a %h'` on the intended + credential directory, if it exists. + +### Cut 1. Heimdall reads secrets from credential files + +- **Repo/branch:** Heimdall, a branch from `e8e2832`. No dependencies. +- **Deletes first:** + - Remove these plaintext secret names from `deployment/idunn/recipe.toml:134-174`: + - `GC_ACCESS_DATABASE_URL`, from `required_environment` (`:173`); + - `GC_ACCESS_APP_BIFROST_SHARED_SECRET`; + - `GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET`; + - `GC_ACCESS_APP_SHARED_SECRET`. No reader exists for it: `src/config.ts:208-216` + reads only per-slug names. Delete it; do not rename it. + - `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET`; + - `GC_ACCESS_PROVIDER_{DISCORD,PATREON,TWITCH,YOUTUBE}_CLIENT_SECRET`; + - `GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64`. + - Remove the inline-PEM input `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM` + (`src/config.ts:281-284`). It is not declared in the recipe, and + `GC_ACCESS_SIGNING_PRIVATE_KEY_PATH` already covers the key: bind it through + `secret_files` under its own name, as `Idunn docs/migration.md:463-472` + describes. + - Client IDs, URLs, TTLs and `GC_ACCESS_APP_*_RUNTIME_IDS` are not secrets and stay. +- **Adds:** + - The recipe gains the `_FILE` twin of every deleted secret name. + `GC_ACCESS_DATABASE_URL_FILE` is **required**; the rest are optional. + - One reader in `src/config.ts`, `readSecretInput(env, name)`: + - it reads `env[name + "_FILE"]` and strips one trailing newline; + - when the `_FILE` variable is absent, it falls back to `env[name]`; + - it throws if **both** are set, so one input has one owner; + - its errors name the variable and the path, never the file's contents. + - The plaintext fallback is a named shim. It protects only the legacy + `EnvironmentFile` unit (P8), and Cut 5 deletes it (Q2). +- **Per-file changes:** + - `src/config.ts:124-139` (`readProviderConfig`, the client secret); + - `:208-216` (app shared secrets); + - `:294-296` (token key); + - `:302-304` (patron secret); + - `:306-308` (database URL); route each through `readSecretInput`; + - `:205`: storage-backend selection must key off the resolved database URL, not + `env.GC_ACCESS_DATABASE_URL`; + - `src/custody.ts:17,23,54`: error text names `GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE`; + - `src/providers.ts:24,33,42,51,65,83`: `clientSecretEnv` becomes the `_FILE` + name, so the published witness (`src/verse-witness.ts:240-243`, names only) + stays truthful. +- **Verification:** + - tests (vitest, a new `tests/config-secrets.test.ts`): + - `_FILE` value is read and has its newline trimmed. Mutant: read `env[name]` + first. It must die. + - Both set is refused. Mutant: file wins silently. + - A missing or unreadable file errors with the variable and path, and the + message contains no byte of a canary written to a sibling file. Mutant: + append the contents to the message. + - The required database-URL file is absent → startup refused. + - StrykerJS scoped to `readSecretInput` and its call sites. + - Negative grep on the recipe returns nothing. It was tested for collision: + `_FILE` names end in `_FILE"` and do not match. + `rg -n '"GC_ACCESS_(DATABASE_URL|[A-Z_]*_SECRET|TOKEN_ENCRYPTION_KEY_BASE64|SIGNING_PRIVATE_KEY_PEM)"' deployment/idunn/recipe.toml` + - Negative grep on source: `rg -n 'env\.GC_ACCESS_(DATABASE_URL|TOKEN_ENCRYPTION_KEY_BASE64|BIFROST_PATRON_SUPPORT_SECRET)\b' src` + returns nothing outside `readSecretInput`. + - Heavy runs go on Yggdrasil via `tools/stopgap/ygg-verify.sh`. + +### Cut 2. Pin the Idunn rule the campaign rests on + +- **Repo/branch:** Idunn, a branch from `46d9c2f`. It has no dependencies and can run + in parallel with Cut 1. +- **Why:** the guarantee "a binding cannot carry an undeclared name" is one line + (`src/deployment.rs:1614-1617`). Its only test, `extra_operator_environment_is_rejected` + (`:2178-2186`), asserts `is_err()`. Any other error satisfies it, which is the + cousin-not-the-rule failure. +- **Changes:** tests only. No behaviour change. + 1. The admit error for an undeclared `[workload.environment]` name is *this* + rule. Assert on the message, or better, on a typed refusal if Hands finds + one is cheap. + 2. The same assertion for an undeclared `[workload.secret_files]` name. + 3. `idunn validate` exits non-zero on an undeclared name, and its output does + not contain the bound value. Use a canary; this pins P6 at the admit layer, + not the TOML layer. +- **Verification:** run `cargo-mutants --in-diff` scoped to `deployment.rs` + `admit`, on Yggdrasil. The mutant `is_subset(..)` → `true` at `:1615` must die + in tests 1 and 2. +- **Deletion line:** none. + +### Cut 3. Yggdrasil: credential files and a secret-free installed binding + +- **Depends on:** Cut 1 merged, and Cut 0 capture read. +- **First:** a copy of the current installed binding goes to + `/root/heimdall.toml.pre-secret-cut`, mode 0400. Take it with `install -m 0400` + and never display it. It is the rollback source until rotation; after rotation + it is shredded (Q3). +- **Transitional copy (Q3-A):** the script is + `gamecult-ops scripts/provision-heimdall-credentials-yggdrasil.sh`, about 40 + lines, modelled on `provision-ghostlight-heimdall-session-yggdrasil.sh`. It + must: + - run as root and refuse otherwise; + - read values from the source Cut 0 chose, without `set -x` and without printing; + - write each value through `mktemp` in a `root:root 0700` directory; + - `chmod 0400` each file and `mv` it into place; + - print only `NAME written (N bytes)`. + + The script is deleted in Cut 5. +- **Binding edit:** + - `[workload.environment]` keeps only non-secret declared names. + - `[workload.secret_files]` gains the `*_FILE` entries. + - `GC_ACCESS_SIGNING_PRIVATE_KEY_PATH` is bound there if it is not already. +- **Verification, in this order, printing only exit codes and counts:** + 1. `idunn validate --recipe --binding ` succeeds. + 2. `idunn validate --recipe --binding /root/heimdall.toml.pre-secret-cut > /dev/null 2>&1; echo $?` + is non-zero. This is the negative check: the old shape is now inadmissible. + Discard the output; it could quote a line. + 3. Run the Cut 0 extractor on the installed binding. Every + `[workload.environment]` name must be in the recipe's non-`_FILE` + declared set. + 4. After `idunn up heimdall`, run + `systemctl show -p Environment --value | tr ' ' '\n' | cut -d= -f1` + to list names only. Every `*_FILE` value starts with + `/run/credentials/`; print only a boolean per name. + 5. The P6 regression probe: make a scratch copy of the new binding with one + line corrupted, and run `idunn validate` on it. Its output contains only paths. +- **Not yet established:** whether `idunn up heimdall` on an unchanged revision + starts a fresh unit, and so reloads a rotated credential. Hands probes it and + reports; the map's rotation step depends on the answer. + +### Cut 4. gamecult-ops: one binding directory, template derived from a secret-free binding + +- **Repo/branch:** gamecult-ops `main` from `62e3f05`. Depends on Cut 3's + verification step 3 passing. From then on, copying the installed binding is + safe by construction. +- **Deletes first:** + - `deployment/idunn-bindings/README.md` (76 lines) and + `deployment/idunn-bindings/heimdall.toml.template` (119 lines): the second + authority and P2. + - `runbooks/heimdall-discord-launch.md:142-150`, the reconstruct-by-`sudo cat` + paragraph (P1). + - `idunn/yggdrasil/bindings/README.md:24-27`, the stale "Heimdall is + intentionally absent" paragraph. +- **Adds:** `idunn/yggdrasil/bindings/heimdall.toml.in`. It is the post-Cut-3 + installed binding, with only the signer id replaced by + `PROVISIONED_HEIMDALL_SIGNER_ID`, following the README's convention. +- **Keeps and moves:** + - Carry one sentence into `idunn/yggdrasil/bindings/README.md`: secret inputs + are `[workload.secret_files]` paths to `root:root 0400` credential files, and + a template never holds a value. + - The README's field and preprovisioning tables duplicate + `Idunn docs/migration.md:440-472`. Point to that instead of moving them. + - Reconcile the install mode. The README says 0644 and + `scripts/install-idunn-yggdrasil-release.sh:32` installs 0640. Make the + README say what the script does. + - Update `scripts/idunn/idunn-deployment-targets.ps1:194` and + `runbooks/heimdall-discord-launch.md` §4 to name the template. +- **Verification:** + - Render the template and diff it against the installed binding on Yggdrasil. + The only difference is the signer-id line. This diff is safe to print only + because Cut 3's step 3 passed. + - Negative greps, each returning nothing: + - `rg -n 'sudo cat /etc/gamecult/idunn/bindings' runbooks` + - `rg -n 'REPLACED_ON_INSTALL' .` + - `rg -n 'deployment/idunn-bindings' . -g '!docs/repo-census-2026-09/**'` + - Run the key-name extractor over `idunn/yggdrasil/bindings/*.toml.in`. No + `[workload.environment]` name ends in `SECRET`, `_KEY`, `PASSWORD`, `TOKEN` + or `DATABASE_URL`. This is a review aid, not an enforcement: enforcement is + admit plus the recipe. + +### Cut 5. Retire the legacy Heimdall path + +- **Depends on:** Heimdall running as an Idunn-admitted generation, and the + legacy unit stopped. If Cut 0 shows Heimdall is not yet admitted, this cut + waits on that admission. It is not a fork. +- **Deletes first:** + - In gamecult-ops: + - `systemd/heimdall.service` (28 lines); + - `scripts/deploy-heimdall.sh` (238); + - `scripts/check-heimdall.sh` (58; it sources `service.env`); + - `scripts/test-heimdall-yggdrasil-wiring.sh` (45); + - `scripts/provision-ghostlight-heimdall-session-yggdrasil.sh`. It sources + `service.env`, and after rotation the operator writes Ghostlight's + copy of the shared secret directly; + - the transitional `provision-heimdall-credentials-yggdrasil.sh`; + - runbook §2 "Upload The Service Env" and the §5 `check-heimdall.sh` steps; + - the `scripts/README.md:33-34,46` entries. + - In Heimdall: the plaintext fallback in `readSecretInput`. + - On Yggdrasil: disable the legacy unit, then `shred -u` + `/srv/heimdall/env/service.env`, every `.service.env.backup.*` and + `.service.env.candidate.*`, and `/root/heimdall.toml.pre-secret-cut`. +- **Verification:** + - `rg -n 'service\.env' scripts runbooks systemd` returns only history notes. + - Heimdall test: setting a plaintext name without its `_FILE` twin no longer + configures the secret. Mutant: restore the fallback. + +## 5. Operator questions + +**Q1. Should Idunn type the secret/plain distinction in the recipe schema?** +- A: No schema change. Heimdall's recipe declares secrets only as `*_FILE` names, + and the existing rule "a binding binds only declared names" does the + enforcing (Cuts 1–2). +- B: Add `required_credentials` and `optional_credentials` to `ServiceDeclaration` + (`src/deployment.rs:112-126`). `admit()` then refuses a declared credential in + `[workload.environment]`, which closes the loophole of binding a `*_FILE` name + to a readable non-credential path. Because of the continuity re-admit (§2), + the rules must be vacuous for old recipes, and every recipe would migrate. + +**Recommended: A.** Neither option stops a recipe author from declaring a secret +as plain environment. Only the author knows what a string means, and B changes +nothing about that. B's real gain is the loophole, and that loophole leaks to +the filesystem, not to transcripts. Record B as a follow-up if a second target +repeats Heimdall's mistake. + +**Q2. Should Heimdall keep the plaintext fallback during the legacy period?** +- A: Keep it as a named shim. Setting both the value and its `_FILE` twin is + refused, and the shim is deleted in Cut 5. +- B: Go file-only now. That requires Heimdall to be Idunn-admitted and the legacy + unit retired in the same move. + +**Recommended: A.** Inventory says the legacy unit is what runs Heimdall today. +Under Idunn the fallback is inert, because admit refuses the plaintext names. + +**Q3. How should rotation be sequenced? ("I'll rotate before prod")** +- A: Land Cuts 1–4 with the current values, copied by the transitional script + without being displayed. Rotate later, before prod, by overwriting the + credential files and redeploying. +- B: Rotate at Cut 3. The operator writes the new values straight into the + credential files and no copy script exists. + +**Recommended: A.** Rotating before the cut would pour the new values into the +same leaky binding. Rotating at the cut blocks it on the operator, and it +cascades: the app shared secrets must change in Ghostlight, StreamPixels and +Bifrost's credential files in the same window. + +Either way: +- Old values stay in every stored plan's `binding_blob`, in history and in + backups (P5). Rotation, not history rewriting, is what makes them dead. +- Rewriting plans breaks `plan_id` (`deployment_plan.rs:662-668`). + +Sub-question for rotation time, if Cut 0 shows `GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64` +was in the binding: rotating that key makes the stored encrypted provider tokens +undecryptable (`src/custody.ts`). Should Heimdall re-encrypt them, or should users +re-link? The answer depends on how many linked accounts exist before prod. The +signing key has the same shape: rotating it invalidates issued tokens. + +## 6. Subtraction ledger (estimate) + +| Cut | Removed | Added | Notes | +|---|---|---|---| +| 1 | ~12 recipe names, inline-PEM path (~5), per-site env reads (~15) | ~12 `_FILE` names, reader (~20), tests (~80) | net-positive only in tests | +| 2 | 0 | ~40 test lines | pins an existing rule | +| 3 | secret values from the installed binding | 1 transitional script (~40) | script dies in Cut 5 | +| 4 | 195 (second binding directory), ~9 runbook lines, ~5 README lines | ~120 template | template replaces the second directory | +| 5 | ~420 ops lines, the shim, the legacy unit, `service.env` + backups | 0 | | + +Follow-ups outside this migration: +- Q1-B, if a second target repeats Heimdall's mistake. +- Docker runner environment is visible through `docker inspect` (Idunn + `src/drivers.rs:2016-2017`). No current binding puts a secret there. +- The P6 echo also applies to recipes. They are repo files and hold no secrets + by construction, so no action is needed.