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

29 KiB
Raw Permalink Blame History

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.