#1942·astrid

Migration barrier asserts over on-disk env/secret scopes it never iterates, blocking boot

Author: jvsteinerCreated Sep 15, 2026Updated Sep 15, 2026

Summary

The legacy env/secret migration barrier imports a principal's scopes by iterating that principal's capsules, then asserts that no native env/secret sources remain for that principal. When a principal owns env/secret scopes but no capsules — or holds scopes for a capsule that has since been uninstalled — nothing is imported, the sources remain, and the assertion fails. Boot aborts with:

Error: Failed to boot Kernel: legacy env/secret sources remain for claude-code (uid 43f45f18…); migration API did not retire every scope

There is no way for an operator to satisfy this: the loop that would retire the scope is keyed on a capsule list that does not contain it.

Mechanism

In import_env_and_secrets (crates/astrid-kernel/src/legacy_migration_barrier/env_import.rs:18) the import is driven by the capsules owned by that principal:

rust
let owner = astrid_storage::StateOwner::Principal(*uid);
let summaries = store.capsules().list(&owner).map_err(storage_io)?;
...
for summary in summaries {
    let capsule = summary.id();
    let env = env_root.join(format!("{capsule}.env.json"));
    let secret = secret_root.join(capsule);
    ...
}

The post-condition (env_import.rs:80-92) is over the filesystem, not over that capsule list:

rust
if let Some(status) = statuses.into_iter().find(|status| {
    status.native_env_present
        || status.native_secret_present
        || !status.unreceipted_capsules.is_empty()
}) {
    return Err(io::Error::other(format!(
        "legacy env/secret sources remain for {} (uid {}); migration API did not retire every scope",
        status.alias, status.uid
    )));
}

Two distinct populations fall through the gap:

  1. Cross-principal ownership. Native capsules live under home/default/.local/capsules/ and migrate to StateOwner::Principal(default_uid). My claude-code principal owned env scopes for astrid-capsule-agents, astrid-capsule-memory, astrid-capsule-openai-compat and claude-runner, plus a secret scope for astrid-capsule-openai-compat — but zero capsules under its own owner. The loop iterated an empty list, imported nothing, and the assertion then failed on the four files it had just declined to look at.

  2. Uninstalled capsules. astrid-capsule-srouter was removed long ago but left secrets/<principal>/astrid-capsule-srouter/ and <principal>.config/env/astrid-capsule-srouter.env.json behind, under both principals. No capsule summary exists, so the scope is never imported and never retired — permanent boot failure.

The error string is itself an acknowledgement that the invariant is not established by the code above it.

Reproduction Steps

  1. Install capsules so they live under home/default/.local/capsules/ (they migrate to the default owner).
  2. Create env/secret scopes for a different principal, e.g. home/claude-code/.config/env/astrid-capsule-memory.env.json and secrets/claude-code/astrid-capsule-openai-compat/.
  3. Separately, uninstall a capsule but leave its env/secret scope on disk (astrid-capsule-srouter in my case).
  4. Run astrid start on 2026.9.2.
  5. Boot aborts with legacy env/secret sources remain for claude-code (uid …); migration API did not retire every scope.

Removing the orphaned astrid-capsule-srouter scopes by hand clears that instance but exposes the next one, and the cross-principal scopes cannot be cleared at all without discarding the secrets.

Expected Behavior

The set the barrier retires and the set it asserts over must be the same set. Concretely:

  • Drive the import from what is on disk for that principal (enumerate env_dir() and secrets_dir()/<alias>), not from the capsule list, so every present scope is considered.
  • Import a scope whose capsule is owned by another principal, or resolve the owner from the capsule registry rather than assuming Principal(uid) of the same alias.
  • Handle scopes for uninstalled capsules explicitly — import, archive, or retire them — rather than leaving them to fail the post-condition.
  • If a scope genuinely cannot be assigned, fail with the offending paths named and an operator-actionable remedy, not a generic "did not retire every scope".

Environment

  • OS: macOS 15.6 (Darwin 24.6.0), arm64
  • Astrid: 2026.9.2, installed via astrid update
  • Source: astrid-runtime/astrid @ 73661c9b (Cargo version 0.10.4)
  • ~/.astrid first created 2026-05-24; last healthy boot on 0.10.4
  • Principals: default (19 native capsules), claude-code (0 capsules, 4 env + 1 secret scope)

Logs / Backtrace

i Starting Astrid daemon (persistent mode)...
2026-09-15T06:35:48.301579Z  INFO astrid_config::loader: loaded user config path=/Users/jamie/.astrid/config.toml
✗ error: Daemon exited prematurely (exit status: 1). Check logs: /Users/jamie/.astrid/log

# ~/.astrid/log/daemon-boot.log
Error: Failed to boot Kernel: legacy env/secret sources remain for claude-code (uid 43f45f180cd89e5a41fa3abb2ab4b555f9ac9cf60f748012351fe84581b01656); migration API did not retire every scope

On-disk state at the time of failure:

home/default/.local/capsules/   -> 19 capsules (migrate to owner `default`)
home/claude-code/.local/capsules/ -> absent

home/claude-code/.config/env/   -> astrid-capsule-agents.env.json
                                   astrid-capsule-memory.env.json
                                   astrid-capsule-openai-compat.env.json
                                   astrid-capsule-srouter.env.json
                                   claude-runner.env.json
secrets/claude-code/            -> astrid-capsule-openai-compat  astrid-capsule-srouter
secrets/default/                -> astrid-capsule-srouter