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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 12:21:48 +02:00
commit 08d346dfae

425
docs/secret-files-cut.md Normal file
View file

@ -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/<unit>/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/<unit>.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/<pid>/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/<name>`. 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/<unit>/`.
- **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 <new recipe> --binding <new binding>` succeeds.
2. `idunn validate --recipe <new 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 <idunn-heimdall unit> | 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.