Idunn/docs/secret-files-cut.md
Meta Vens b69a8e81ba Secrets map: Heimdall secrets merged at ca2366c after Soul pass 7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:49:57 +02:00

475 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<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.