29 KiB
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
loadConfigactually reads. - F4:
checkSchemaruns on every start.
- F1: command-plane diagnostics are fixed text plus the error's class and code, and every provider body goes
through
- Soul pass 5: hold. F1-F3 held. Batch 6, in Hands, fixes:
- S5-1.
checkSchemapasses a DEFERRABLE key, aUNIQUE ... 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:1310writes a live, 300 s redeemable completion code into the audit eventauth_completion_created. - S5-4 to S5-7. Test gaps: canary position, the
errorIdentityfilters, pool close, spread-env reads. - A flaky live-Postgres test.
- S5-1.
- 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
causeis 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
indisvalidwith 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.
- Keys must be
- Soul pass 7: merge. Nothing blocks.
- 1400 predicate pairs were checked, and no two different predicates compared equal.
- The
indisvalidargument 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-150says to reconstruct the missing template from the installed binding withssh 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>/NAMEin 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
*_FILEnames (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 reachdocker run -eanddocker inspect. - No current template puts a secret there, and runner
secret_filesalready 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
*_FILEnames; 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
*_FILEpaths from its environment and the bytes from those files. - Idunn reads binding paths and stats the files.
- Heimdall reads
- Outputs: systemd credentials under
/run/credentials/<unit>/. - Derived state:
- The binding's
[workload.secret_files]holds paths only. - The transient unit's
LoadCredential=andEnvironment=hold paths only. - The plan blob holds paths only.
- The binding's
- Forbidden holders of a secret value:
[workload.environment];- any
*.toml.inor template; - recipe
required_environmentandoptional_environment; - READMEs and runbooks;
/srv/heimdall/env/service.envafter 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 catof a single-purpose credential file, and that is a deliberate act. - Shared paths: the same
readSecretInputreader serves legacy start, Idunn start and tests (Cut 1). The sameadmit()servesidunn validate, binding load and continuity re-admit. - Deletion line:
- Heimdall's plaintext secret names in the recipe (Cut 1).
deployment/idunn-bindings/and the runbooksudo catstep (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 withsudo 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:
sha256each value inside one root shell, and emitNAME equal|differ. This decides whether Cut 3 copies fromservice.envor from the binding. - What runs Heimdall now:
systemctl is-active heimdall.service, plus the names of theidunn-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, fromrequired_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-216reads 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, andGC_ACCESS_SIGNING_PRIVATE_KEY_PATHalready covers the key: bind it throughsecret_filesunder its own name, asIdunn docs/migration.md:463-472describes. - Client IDs, URLs, TTLs and
GC_ACCESS_APP_*_RUNTIME_IDSare not secrets and stay.
- Remove these plaintext secret names from
- Adds:
- The recipe gains the
_FILEtwin of every deleted secret name.GC_ACCESS_DATABASE_URL_FILEis 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
_FILEvariable is absent, it falls back toenv[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.
- it reads
- The plaintext fallback is a named shim. It protects only the legacy
EnvironmentFileunit (P8), and Cut 5 deletes it (Q2).
- The recipe gains the
- 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 throughreadSecretInput;:205: storage-backend selection must key off the resolved database URL, notenv.GC_ACCESS_DATABASE_URL;src/custody.ts:17,23,54: error text namesGC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE;src/providers.ts:24,33,42,51,65,83:clientSecretEnvbecomes the_FILEname, so the published witness (src/verse-witness.ts:240-243, names only) stays truthful.
- Verification:
- tests (vitest, a new
tests/config-secrets.test.ts):_FILEvalue is read and has its newline trimmed. Mutant: readenv[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
readSecretInputand its call sites. - Negative grep on the recipe returns nothing. It was tested for collision:
_FILEnames 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' srcreturns nothing outsidereadSecretInput. - Heavy runs go on Yggdrasil via
tools/stopgap/ygg-verify.sh.
- tests (vitest, a new
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), assertsis_err(). Any other error satisfies it, which is the cousin-not-the-rule failure. - Changes: tests only. No behaviour change.
- 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. - The same assertion for an undeclared
[workload.secret_files]name. idunn validateexits 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.
- The admit error for an undeclared
- Verification: run
cargo-mutants --in-diffscoped todeployment.rsadmit, on Yggdrasil. The mutantis_subset(..)→trueat:1615must 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 withinstall -m 0400and 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 onprovision-ghostlight-heimdall-session-yggdrasil.sh. It must:- run as root and refuse otherwise;
- read values from the source Cut 0 chose, without
set -xand without printing; - write each value through
mktempin aroot:root 0700directory; chmod 0400each file andmvit 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*_FILEentries.GC_ACCESS_SIGNING_PRIVATE_KEY_PATHis bound there if it is not already.
-
Verification, in this order, printing only exit codes and counts:
idunn validate --recipe <new recipe> --binding <new binding>succeeds.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.- Run the Cut 0 extractor on the installed binding. Every
[workload.environment]name must be in the recipe's non-_FILEdeclared set. - After
idunn up heimdall, runsystemctl show -p Environment --value <idunn-heimdall unit> | tr ' ' '\n' | cut -d= -f1to list names only. Every*_FILEvalue starts with/run/credentials/; print only a boolean per name. - The P6 regression probe: make a scratch copy of the new binding with one
line corrupted, and run
idunn validateon it. Its output contains only paths.
-
Not yet established: whether
idunn up heimdallon 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
mainfrom62e3f05. 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) anddeployment/idunn-bindings/heimdall.toml.template(119 lines): the second authority and P2.runbooks/heimdall-discord-launch.md:142-150, the reconstruct-by-sudo catparagraph (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 byPROVISIONED_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 toroot:root 0400credential 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:32installs 0640. Make the README say what the script does. - Update
scripts/idunn/idunn-deployment-targets.ps1:194andrunbooks/heimdall-discord-launch.md§4 to name the template.
- Carry one sentence into
- 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' runbooksrg -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 inSECRET,_KEY,PASSWORD,TOKENorDATABASE_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 sourcesservice.env);scripts/test-heimdall-yggdrasil-wiring.sh(45);scripts/provision-ghostlight-heimdall-session-yggdrasil.sh. It sourcesservice.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.shsteps; - the
scripts/README.md:33-34,46entries.
- 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.
- In gamecult-ops:
- Verification:
rg -n 'service\.env' scripts runbooks systemdreturns only history notes.- Heimdall test: setting a plaintext name without its
_FILEtwin 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
*_FILEnames, and the existing rule "a binding binds only declared names" does the enforcing (Cuts 1–2). - B: Add
required_credentialsandoptional_credentialstoServiceDeclaration(src/deployment.rs:112-126).admit()then refuses a declared credential in[workload.environment], which closes the loophole of binding a*_FILEname 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
_FILEtwin 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(Idunnsrc/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.