1 Home Management Plan
crussell edited this page 2026-09-09 19:32:21 -04:00

bubblebox Home Management — Design Doc (Phase B/C)

For Hermes: This is a design document, not a step-by-step implementation plan. Read in full before implementing. Pair with GC-and-Generations and Devshells — this file follows the same hub-and-spoke convention.

Goal: Give bubblebox the ability to declaratively manage host-resident files and systemd user units — the home-manager analog — so cjust link and cjust units are replaced by bubblebox apply.

Architecture: A new declarative layer in profile.toml ([[files]] and [[units]] sections) that apply reconciles alongside the existing package generation. File contents are hashed into the content-addressed store as blobs (exactly like package contents), and $HOME entries are symlinked to the resulting plain store paths. Removed-from-profile entries are dropped on apply; blobs live until gc, protected as roots while any generation references them. Units are the same pattern targeting ~/.config/systemd/user/, plus a systemctl hook.

The one-line model: a managed dotfile is $HOME/<path> → ~/.local/share/bubblebox/store/objects/<ab>/<cdef…> — a plain file path, not a FUSE mount. Content-addressed like nix, rock-solid like a real file, declarative like everything else in bubblebox.


1. What this solves

Currently, host-resident files on the thinkpad are managed by cjust link (a Justfile recipe that symlinks from dotfiles/ into $HOME) and cjust units (same, for systemd). These have limitations that bubblebox can solve:

Current (cjust) Problem bubblebox solution
dotfiles list is a space-separated string in the Justfile Must be manually synced; easy to forget an entry [[files]] in profile.toml — part of the declarative profile
cjust link doesn't remove files you stop managing Stale symlinks accumulate apply diffs against the previous generation, removes dropped entries
No tracking of what's managed vs what's user-owned Can't tell managed from manual files The generation manifest records every path bubblebox owns
cjust units is separate from cjust link Two commands, two lists, same problem Unified under apply
No rollback for file state Removing a dotfile is permanent until you re-run cjust link Generation-scoped: bubblebox generations switch restores old file set
Symlinks point into a mutable git checkout Repo moves/renames break $HOME links; not self-contained Symlinks point into the store, independent of any checkout

2. The three layers (recap from design discussion)

Not all dotfiles need the same approach. The categorization:

Layer Files Where they're read Solution
A. In-sandbox configs nvim, wezterm, ghostty, vicinae, aws Inside bwrap, via writable_home or binds Entrypoint binds (Phase A — no new machinery)
B. Host-resident files .zshrc, .zshenv, compositor configs, desktop entries, wrapper scripts, opencode By host processes (zsh, niri, COSMIC, desktop env) This design — [[files]] in profile.toml
C. Systemd user units opencode-web.service, vicinae.service By systemd (reads ~/.config/systemd/user/) This design — [[units]] in profile.toml

Phase A (in-sandbox configs) is a separate, smaller change. This doc covers Phase B+C.

Decision rule: Phase A bind vs. [[files]]

A config goes to Phase A sandbox binds iff only sandboxed tools read it; to [[files]] iff a host process reads it. If both read it, [[files]] wins and the Phase A bind targets the host path (the host copy is the single source the sandbox then mounts). This prevents the same config living in two places. Example: opencode is read by the host directly → [[files]], and the sandbox bind for any tool needing it points at the host path.

3. Design decisions

# Decision Rationale
1 [[files]] and [[units]] live in profile.toml Single declarative input (the flake.nix analog). Host-specific files don't belong in the package source repo.
2 File contents are hashed into the content-addressed store; $HOME symlinks point at plain store paths The core decision. Store blobs sit at ~/.local/share/bubblebox/store/objects/ as real files (the FUSE mount is a separate presentation layer for package rootfs trees, never in the dotfile read path). So .zshrc → a real path that survives FUSE unmounts, cn repo moves, and checkouts. Same store, same content-addressing, same philosophy as packages.
3 No live-edit; changing a file means editing the source and re-running apply Deliberate feature, not a limitation. It's the declarative discipline — identical to home-manager (edit → rebuild). Live iteration for sandboxed tools is Phase A binds' job; host-resident files want stability, not liveness.
4 Source files live in files/ and units/ directories relative to profile.toml Convention over configuration. files/ mirrors $HOME layout. The source is the input to hashing; once applied, the store blob is the truth and the checkout is no longer referenced by any $HOME link.
5 The generation manifest records what bubblebox owns (no separate managed.toml) One source of truth. apply diffs the previous generation's file/unit list against the new one to compute removals — the same way it already diffs roots for wrappers. A separate managed.toml would be redundant state that can drift from the lock/manifest.
6 Garbage collection is generation-scoped, and file blobs are GC roots Removing a file from profile.toml → apply drops the symlink → blob becomes unreferenced → next gc sweeps it. While any generation references the blob, gc keeps it alive (extends the existing root set: generations + lock + bindings + live mounts). Rolling back repoints to the old blob, which gc has kept.
7 Units are files with a post-install hook A unit is just a file at a specific destination (~/.config/systemd/user/) plus systemctl --user daemon-reload and optional enable --now. Not a new concept.
8 Directories: kind = "dir" walks the tree into per-file store blobs A directory can't be a single blob. (Nix symlinks a whole dir because the nix store holds content-addressed directories; bubblebox's store holds flat blobs — dirs only exist inside FUSE-mounted composefs descriptors. Storing a CA tree would be a new store capability, out of scope.) So a dir entry hashes each contained file and recreates the tree at $HOME as real dirs + per-file symlinks into the store.
9 Real dirs in $HOME are created as needed but never removed if they contain non-bubblebox files The .config/opencode case: bubblebox creates the real dir and per-file symlinks, but auth.json (machine-local, user-owned) lives there too. bubblebox only ever removes its own symlinks; it never removes a real dir that still holds anything it didn't create. Bulletproof for user secrets.
10 Conflict detection: back up non-managed files before linking If $HOME/.zshrc exists and is not a bubblebox symlink, apply renames it to .bak.<timestamp> first, matching current cjust link behavior. Never silently overwrite user data.
11 Phase A binds and Phase B files coexist Some configs move into entrypoint binds (Phase A); others stay host-resident (Phase B). The [[files]] list only contains what must be on the host.

4. Schema

4.1 profile.toml additions

# Existing fields unchanged
packages = ["nvim", "rg", "fd", ...]

# ── NEW: host-resident file management ──────────────────────
# Source files live in `files/` next to profile.toml (convention,
# not config), mirroring $HOME layout:
#   hosts/thinkpad/bubblebox/files/.zshrc
#   hosts/thinkpad/bubblebox/files/.config/oh-my-posh.json
#   hosts/thinkpad/bubblebox/files/.config/opencode/        (a dir)
#
# apply hashes each file's content into the content-addressed
# store (store/objects/<ab>/<cdef…>) and symlinks $HOME/<dst> →
# that plain store path. `src` defaults to `dst` (mirror layout).
# `dst` is $HOME-relative. Edits land only on re-apply (declarative).

[[files]]
dst = ".zshrc"

[[files]]
dst = ".zshenv"

[[files]]
dst = ".config/oh-my-posh.json"

[[files]]
dst = ".config/niri/config.kdl"

[[files]]
dst = ".config/environment.d/10-bubblebox.conf"

[[files]]
dst = ".local/bin/zen-browser"

[[files]]
dst = ".local/bin/hermes-desktop"

[[files]]
dst = ".local/bin/buzz-desktop"

# .desktop entries
[[files]]
dst = ".local/share/applications/wezterm.desktop"
[[files]]
dst = ".local/share/applications/ghostty.desktop"
[[files]]
dst = ".local/share/applications/hermes.desktop"
[[files]]
dst = ".local/share/applications/buzz.desktop"

# A directory entry: each contained file is hashed into the store;
# the tree is recreated at $HOME/.config/opencode as real dirs +
# per-file symlinks. Machine-local files already there (auth.json)
# are untouched — bubblebox only manages the symlinks it creates.
[[files]]
dst = ".config/opencode"
kind = "dir"

# ── NEW: systemd user unit management ───────────────────────
# Unit sources live in `units/` next to profile.toml:
#   hosts/thinkpad/bubblebox/units/opencode-web.service
# Each entry symlinks ~/.config/systemd/user/<name>.service → the
# store blob, then runs `systemctl --user daemon-reload`.
# Optional `enable = true` runs `systemctl --user enable --now <name>`.
# Enable failures warn and continue (unit file is still linked).
[[units]]
name = "opencode-web"
enable = true

[[units]]
name = "vicinae"
enable = true

4.2 Generation manifest additions (the ownership record)

The generation manifest (generations/<hash>.toml / profile.lock) gains file and unit sections so a generation is a complete snapshot of the managed state. This is the only ownership record — there is no separate managed.toml. apply diffs the previous generation against the new one to compute removals, exactly as it already diffs roots for wrappers.

# ... existing package fields ...

[[file]]
dst = ".zshrc"
blob = "ab12cd34…"          # store digest this generation pins
kind = "file"               # or "dir" (with one entry per contained file)

[[unit]]
name = "opencode-web"
enabled = true

Recording the blob digest per entry is what makes generations switch restore the exact prior content and lets gc treat referenced blobs as roots.

5. apply flow (updated)

The existing apply function in profile.rs gains phases after materialize_bindings + sync_exports:

apply():
  ... existing package resolution, pull, generation creation ...

  // EXISTING
  materialize_bindings(desc_dir, &gen)
  sync_exports(prev_roots, gen.roots)

  // NEW Phase 1: hash declared file/dir contents into the store,
  //              collect (dst → blob digest) pairs
  let file_entries = ingest_files(common, &profile.files)?;   // writes blobs, returns entries

  // NEW Phase 2: reconcile $HOME symlinks against prev generation
  sync_files(common, &prev_gen.file, &file_entries)?;

  // NEW Phase 3: reconcile units (files + systemctl)
  sync_units(common, &prev_gen.unit, &profile.units)?;

  // NEW Phase 4: daemon-reload if units changed
  if units_changed { systemctl --user daemon-reload }

5.1 ingest_files — hash contents into the store

For each [[files]] entry, hash the source content and write it as a store blob (sharded objects/<ab>/<rest>, same layout as package blobs). Dir entries walk the tree and ingest each contained file. Returns the resolved (dst, blob, kind) list that becomes the generation's [[file]] entries.

fn ingest_files(common: &Common, files: &[FileEntry]) -> Result<Vec<ResolvedFile>> {
    let base = common.profile_dir.join("files");
    let mut out = Vec::new();
    for entry in files {
        let src = base.join(entry.src.as_deref().unwrap_or(&entry.dst));
        match entry.kind.unwrap_or(FileKind::File) {
            FileKind::File => {
                let digest = store_blob(&common.store, &src)?;   // hash + write, idempotent
                out.push(ResolvedFile { dst: entry.dst.clone(), blob: digest, kind: File });
            }
            FileKind::Dir => {
                for rel in walk_files(&src)? {                  // each file under the dir
                    let digest = store_blob(&common.store, &src.join(&rel))?;
                    out.push(ResolvedFile { dst: join(&entry.dst, &rel), blob: digest, kind: DirFile });
                }
            }
        }
    }
    Ok(out)
}

store_blob is content-addressed: same content → same digest → no rewrite. Re-applying an unchanged profile re-hashes to identical digests and is a no-op.

5.2 sync_files — the core reconcile logic

fn sync_files(common: &Common, prev: &[ResolvedFile], new: &[ResolvedFile]) -> Result<()> {
    let home = std::env::var("HOME")?;
    let store = &common.store;
    let new_set: HashMap<&str, &ResolvedFile> = new.iter().map(|f| (f.dst.as_str(), f)).collect();
    let prev_set: HashMap<&str, &ResolvedFile> = prev.iter().map(|f| (f.dst.as_str(), f)).collect();

    // Add/update: point $HOME/<dst> at the store blob
    for entry in new {
        let target = store.join(blob_relpath(&entry.blob));     // objects/<ab>/<rest>
        let dst = PathBuf::from(&home).join(&entry.dst);

        // Already correct? compare the symlink's target LEXICALLY first
        // (no canonicalize — store paths are plain files, and canonicalize
        // can mis-resolve if any component is FUSE-adjacent).
        if symlink_target_eq(&dst, &target) { continue; }

        // Conflict: back up if not already our symlink
        if dst.exists() && !symlink_target_eq(&dst, &target) {
            backup(&dst)?;                                      // rename to .bak.<timestamp>
        }
        create_dir_all(dst.parent().unwrap())?;
        std::os::unix::fs::symlink(&target, &dst)?;
    }

    // Remove: drop symlinks for entries no longer in the profile.
    // Only remove if it's OUR symlink (target resolves into the store) —
    // never remove a regular file or a foreign symlink.
    for (dst, _) in &prev_set {
        if !new_set.contains_key(dst) {
            let path = PathBuf::from(&home).join(dst);
            if is_store_symlink(&path, &store) {
                std::fs::remove_file(&path)?;
            }
        }
    }
    Ok(())
}

Key safety rules:

  • Never remove a file you didn't create. Removal verifies the path is a symlink whose target resolves into the store (is_store_symlink). A regular file or a symlink to elsewhere is left alone with a warning.
  • Backup conflicts. A real file at $HOME/.zshrc is backed up before linking (matches cjust link).
  • Real dirs are created but never removed if non-empty-of-ours. For dir entries, bubblebox creates real parent dirs in $HOME and removes only its own per-file symlinks; a real dir that still contains a non-bubblebox file (e.g. auth.json) is never removed.
  • Ownership check is lexical-first. Compare the stored symlink target string to the expected store path before any canonicalize, so a stale/adjacent FUSE mount can't cause a false "not ours" → spurious re-backup.

5.3 sync_units — file reconcile + systemctl integration

Units are files with a hook. sync_unit_files reuses the same ingest+reconcile path (src = units/<name>.service, dst = ~/.config/systemd/user/<name>.service), then:

fn sync_units(common: &Common, prev: &[UnitEntry], new: &[UnitEntry]) -> Result<()> {
    sync_unit_files(common, prev, new)?;    // same blob+symlink reconcile as files

    // daemon-reload is always safe after unit file changes; skip gracefully
    // if systemctl is absent (non-systemd host) — files are still linked.
    if !new.is_empty() || !prev.is_empty() {
        let _ = run("systemctl", &["--user", "daemon-reload"])
            .map_err(|e| warn!("systemctl daemon-reload failed: {e}"));
    }

    // Enable newly-enabled units; stop+disable removed/renamed units.
    for unit in new {
        if unit.enable && !prev.iter().any(|p| p.name == unit.name && p.enable) {
            let _ = run("systemctl", &["--user", "enable", "--now", &unit.name])
                .map_err(|e| warn!("enable {} failed: {e}", unit.name));
        }
    }
    for unit in prev {
        if unit.enable && !new.iter().any(|n| n.name == unit.name && n.enable) {
            // Stop too (not just disable) so a renamed/removed unit doesn't
            // keep running under a name nothing manages.
            let _ = run("systemctl", &["--user", "disable", "--now", &unit.name])
                .map_err(|e| warn!("disable {} failed: {e}", unit.name));
        }
    }
    Ok(())
}

Notes:

  • Renames are handled: a rename = remove-old + add-new; old is stopped+disabled, new is enabled. No orphaned running service.
  • --now on disable stops the unit; acceptable because it's only for units removed from the profile.
  • Timers: if a unit needs a .timer companion, declare it as a separate [[units]] entry (name without suffix maps to <name>.timer); the schema is not hardcoded to .service — name carries the unit stem and the source file's extension determines the type.

6. CLI

No new commands. apply does the work:

bubblebox apply              # realize packages + files + units
bubblebox apply --refresh    # also re-resolve floating package inputs
bubblebox apply --no-export  # skip wrapper sync (existing flag; also skips files/units)

generations switch / generations list automatically include file/unit state in their scope — switching repoints $HOME links to the target generation's blobs.

7. Migration from cjust

Current cjust recipe Replacement
cjust link bubblebox apply (files section)
cjust units bubblebox apply (units section)
The dotfiles := "..." string in Justfile [[files]] entries in profile.toml
The user_units := "..." string in Justfile [[units]] entries in profile.toml
cjust cosmic-backup / cjust cosmic-restore Stays as-is — permanent exception (COSMIC can't be symlinked)

Migration plan

  1. In the cn repo: rename hosts/thinkpad/dotfiles/ → hosts/thinkpad/bubblebox/files/ (next to profile.toml). Move hosts/thinkpad/systemd/user/ → hosts/thinkpad/bubblebox/units/.
  2. Add [[files]] and [[units]] to profile.toml, importing the Justfile's dotfiles and user_units lists. Mark .config/opencode (and any other dir) as kind = "dir"; expand it to per-file entries only if you'd rather not grant dir semantics.
  3. bubblebox apply — hashes contents into the store, creates $HOME symlinks (backs up conflicts, same as cjust link).
  4. Verify ~/.zshrc etc. resolve into store/objects/… and shells start cleanly.
  5. Remove cjust link / cjust units recipes and the dotfiles / user_units strings from the Justfile.

8. The files/ and units/ convention

hosts/thinkpad/bubblebox/
  profile.toml          # declares [[files]] and [[units]]
  files/                # mirrors $HOME layout (formerly dotfiles/)
    .zshrc
    .zshenv
    .config/
      oh-my-posh.json
      niri/config.kdl
      environment.d/10-bubblebox.conf
      opencode/               # kind = "dir"
        opencode.json
        skills/...
    .local/bin/
      zen-browser  hermes-desktop  buzz-desktop
    .local/share/applications/
      wezterm.desktop  ghostty.desktop  hermes.desktop  buzz.desktop
  units/                # systemd user units (formerly systemd/user/)
    opencode-web.service
    vicinae.service

The cn repo remains the source of truth for file contents; bubblebox owns the store blobs and the $HOME symlink lifecycle.

Why content-addressed store over symlink-into-checkout: the store makes $HOME links independent of the cn checkout (repo moves, renames, or a fresh clone don't dangle your .zshrc), gives rollback the exact prior content for free (the old generation pins the old blob), and matches bubblebox's existing content-addressed philosophy. Symlinks point at store/objects/ — real files, never the FUSE mount — so there's no mount dependency in the dotfile read path.

9. COSMIC desktop settings — out of scope

COSMIC settings (~100 RON files under .config/cosmic/) can't be symlinked (COSMIC breaks). They stay as manual cjust cosmic-backup / cjust cosmic-restore (copy-based) and are not managed by bubblebox. Permanent exception, not a deferred feature.

10. Store & state layout additions

~/.local/share/bubblebox/
  store/objects/<ab>/<cdef…>      NOW ALSO holds managed-file blobs (sharded, same as packages)
  generations/<hash>.toml         NOW INCLUDES [[file]] (with blob digest) + [[unit]] entries

~/.config/bubblebox/
  profile.toml                    NOW INCLUDES [[files]] and [[units]]
  profile.lock                    NOW INCLUDES file/unit entries

No managed.toml. The generation manifest is the single ownership record.

11. GC implications

The existing GC walks generations + lock + bindings + live mounts to build the live-digest set, then sweeps unreferenced blobs. One change: managed-file blob digests (recorded in each generation's [[file]].blob and the lock) are added to the live set, so gc keeps a file blob alive while any generation references it.

  • Remove a file from the profile → apply drops the $HOME symlink → blob unreferenced → next gc sweeps it. No dangling link, because the link is removed before the blob can be collected.
  • Rollback: generations switch to an older generation repoints links to that generation's blobs, which gc has kept alive (still referenced by that generation until it's pruned).
  • Residual window (documented, narrow): switching to an old generation, then pruning it + gc, can leave a live $HOME symlink pointing at a swept blob. Mitigation: gc warns if it's about to sweep a file blob that a current $HOME symlink resolves to (best-effort check, same spirit as the existing "don't gc during apply/run" edge). Non-destructive (dangling ≠ data loss; source is in cn git), self-healing on next apply, and rare (requires deliberate prune of the active generation).

12. Risks & open questions

Risk Mitigation
apply removes a file the user wanted Only remove symlinks whose target resolves into the store; never remove regular files; back up conflicts.
Store blob swept while a link points at it File blobs are GC roots while referenced; gc warns before sweeping a blob a live $HOME link resolves to.
systemctl not available (non-systemd host) sync_units manages the files, skips daemon-reload/enable with a warn.
Unit enable fails (missing dependency) Warn and continue; the unit file is still linked.
kind = "dir" over a dir with user secrets bubblebox only removes its own symlinks; never removes a real dir containing non-bubblebox files (auth.json is safe). User's responsibility to not dir-manage a tree they want partly unmanaged.
False "not ours" from canonicalize through FUSE Ownership check compares stored symlink target lexically first; canonicalize only as fallback.
profile.toml gets long TOML array-of-tables is compact; a future glob/include could help if it grows.

Open questions (non-blocking):

  • Should generations switch re-run sync_units' enable/disable, or only repoint file links? (Leaning: repoint links + daemon-reload, leave enable/disable to apply.)
  • Glob support for [[files]] (dst = ".local/share/applications/*.desktop") — defer to a follow-up if the list grows.

13. What Phase A looks like (for reference)

Phase A (in-sandbox configs via entrypoint binds) is orthogonal. It moves nvim/wezterm/ghostty/vicinae configs from the host into the bubblebox-pkgs source repo, declared as binds in each tool's entrypoint.toml:

# bubblebox-pkgs/nvim/entrypoint.toml (Phase A addition)
[[sandbox.bind]]
src = "$BUBBLEBOX_SOURCE/personal/nvim/config"
dst = "/home/crussell/.config/nvim"
mode = "ro"

Configs read only by sandboxed tools move here (live-edit for iteration). Configs read by host processes (opencode) stay as [[files]]. The two phases coexist; see §2's decision rule.

14. Non-goals (v1)

  • Template substitution in file contents (home-manager's home.file.<name>.text)
  • File permissions/ownership management (beyond the executable bit already on the source)
  • Storing a content-addressed directory tree as a single store object (Nix-style; a new store capability — kind = "dir" decomposes to per-file blobs instead)
  • System-level units (only --user units)
  • Cross-host file distribution (files are local to the machine)
  • COSMIC settings management (copy-based, stays manual)
  • Live-editing of host files (deliberate: re-apply to change; Phase A binds cover live iteration for sandboxed tools)