# 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". **Heimdall `hands/secret-files` status (Self, 2026-09-30).** - **Batch 5** (`efd1137..b5d7b6b`) fixed Soul pass 4's four findings: - F1: command-plane diagnostics are fixed text plus the error's class and code, and every provider body goes through `readProviderJson`. - F2: patron support keeps only the Bifrost status. - F3: the recipe test records the secrets `loadConfig` actually reads. - F4: `checkSchema` runs on every start. - **Soul pass 5: hold.** F1-F3 held. Batch 6, in Hands, fixes: - **S5-1.** `checkSchema` passes a DEFERRABLE key, a `UNIQUE ... INCLUDE`, a same-named non-unique replacement for the partial unique index, and retyped columns. Each of these breaks the store at runtime. - **S5-2.** The privilege check demands UPDATE on `audit_events`, which the store only INSERTs into. - **S5-3 (older than this batch).** `app.ts:1310` writes a live, 300 s redeemable completion code into the audit event `auth_completion_created`. - **S5-4 to S5-7.** Test gaps: canary position, the `errorIdentity` filters, pool close, spread-env reads. - A flaky live-Postgres test. - Noted, not in scope: **S5-8**, forced RLS with no policy passes the check. - **Batch 6** (`b5d7b6b..6c0c876`): S5-1 through S5-7 fixed, and the flaky live test fixed (template DB clone, `fixturePool`). Result: 241 passed, 24/24 live, 40 of 41 mutants killed. The survivor, X13, is equivalent: pg-pool destroys a failed client. - **Soul pass 6: hold.** Batch 7 is in Hands. - **F1.** An invalid or not-ready unique index passes the check. That breaks sign-in (42P10) and R21.1, because a consume takes 2 rows. - **F2.** A key index under a nondeterministic collation passes, and the upsert then moves one account's identity row to another account. - **F3.** The predicate normaliser is unsound: it strips parentheses and lowercases literals. - **F4.** Some databases the store could serve are refused: indexes are required by name, and column-level grants are not accepted. - **F5.** The recipe exploration's all-at-once run stops early; mutants Z4, Z5 and Z6 survive. - **F6-F8.** An inspected `cause` is not covered, a narrowed predicate has no fixture, and an added NOT NULL on a nullable column is not caught. CHECK constraints and triggers are out of scope, as S5-8 is. - **Batch 7** (`6c0c876..ca2366c`): F1-F8 fixed. - Keys must be `indisvalid` with deterministic collations. - Predicates are printed by Postgres, using a TEMP table in a rolled-back transaction, and compared as sets of AND conditions. - Index names are not required. Column privileges are checked with `has_column_privilege`. A nullable column must stay nullable. - Results: 241 passed, 39/39 live, 60/61 mutants killed. - **Soul pass 7: merge.** Nothing blocks. - 1400 predicate pairs were checked, and no two different predicates compared equal. - The `indisvalid` argument held under interrupted concurrent DDL. - TEMP is never refused for the real deployment, because the service role owns the database. - **MERGED: Heimdall main fast-forwarded to `ca2366c` (2026-09-30).** - Follow-up branch in Hands: - S7-1: the recipe exploration never runs the production start shape. - S7-2: extra unique indexes and similar documented as out of scope. - S7-3: the column collation must be deterministic. - S7-4: one more fixture each for Y3, Y4, Y5 and Y8. - **Recorded:** a least-privilege column-subset grant is refused (F4(b)). That is theoretically servable, but it is not how Heimdall is deployed. **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.