Table of Contents
- Codebase & UX Review — 2026-08-21
- Executive summary
- Part 1 — Technical review
- Strengths (keep)
- Findings
- T1 · P0 · data-loss risk — ensure_parent_dir deletes foreign symlinks
- T2 · P0 · lock written before materialization
- T3 · P0 · doc drift — AGENTS.md declares itself source of truth but lags
- T4 · P0.5 · no doctor; lazy dep discovery; userns restrictions
- T5 · P2 · robustness nits (network paths)
- T6 · P2 · security posture (documented, restate for distribution)
- bubblebox-pkgs
- Part 2 — UX review vs. nix + flakes
- Priorities
- Ecosystem design — engine work items (2026-08-26)
- Roadmap (agreed direction, 2026-08-21)
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.
Codebase & UX Review — 2026-08-21
Full review of the bubblebox engine (~/Code/bubblebox) and the associated
package source (bubblebox-pkgs), with an eye toward the goal: installable
easily on any distro, then managing packages/configs/shells at least as easily
as modern nix + flakes. This file is the working tracker — check items off as
they're triaged into designs or landed. (Precedent: the retired CODE_REVIEW.md
tracker; see git log.)
Scope covered: all of cli/src + fuse-mount, GC-and-Generations,
Devshells, the home-management plan (Phase B/C),
bubblebox-pkgs Containerfiles/entrypoints, the live host profile.toml +
store, and the cn Justfile integration. Test suite verified 76/76 passing;
live generation listed (23 pkgs, 3.3 GB store).
Executive summary
The engine is in unusually good shape: small (~6.4k lines), disciplined
(clippy-deny, 76 unit tests on the pure logic), full of hard-won correctness
(stale-FUSE avoidance, atomic rename publication, content-addressed
generation IDs). The declarative layer (profile.toml/profile.lock/
generations/GC) is a genuine flake.nix/flake.lock analog; the day-to-day
edit → apply → wrappers sync loop matches nix's ergonomics, often beats it.
Two things stand between it and the stated goal:
- No install story. No release artifacts, no README, no portable binary, six undocumented host deps discovered lazily.
- Empty ecosystem by design. Every tool must be hand-authored in
bubblebox-pkgs or pushed to your own cache. nix's real moat isn't UX — it's
nixpkgs. Cheap near-term mitigation: an
importcommand turning arbitrary OCI images (Docker Hub) into packages.
Everything else is polish.
Part 1 — Technical review
Strengths (keep)
- FUSE constraint correctness: never readdir stale mounts; PID liveness via
/proc/<pid>/commguards against reuse;mountpoint(1)stat-based checks (mount.rs:117-140). Subtle stuff done right, documented in place. - Atomic state transitions: staging + rename everywhere — descriptor
publish (
composefs.rs:78), binding repoint via temp-symlink rename (composefs.rs:103; rename-over-open is Unix-clean for running daemons), pull→verify→publish (cache.rs:118-133). - Generation identity (
profile.rs:182): order-independent, origin/time- excluded, includes files+units so dotfile changes create rollback-able generations. Well tested. - Actionable errors throughout:
resolve_sourcenames missing source dirs (build.rs:196-213);resolve_indexuses nix-substituter semantics (404 = skip, transport error = warn+skip);shell's missing-closure error says exactly what to run. - GC safety posture: dry-run default; pkg bindings root imperative
packages; file/unit blobs rooted per-generation (
gc.rs:29-103).
Findings
T1 · P0 · data-loss risk — ensure_parent_dir deletes foreign symlinks
Status: FIXED 2026-08-21 — now removes only dangling symlink ancestors; live dir-symlinks are left untouched (create_dir_all follows them); a live non-dir link is backed up first. 3 new unit tests.
(home.rs:314-338) Before creating parents for e.g. $HOME/.config/foo/bar,
it walks ancestors and removes any symlink it finds — no backup, no
ownership check. If a user symlinks ~/.config → their synced-dotfiles dir
(common), applying .config/nvim/init.lua deletes that symlink and replaces
it with a real directory, stranding everything behind it. The comment says
this handles dangling cjust dir-links, but the code can't distinguish dangling
from live or owned from foreign.
Fix: only remove symlinks whose target doesn't exist (dangling);
otherwise back up like backup() does, or refuse with a clear message.
T2 · P0 · lock written before materialization
Status: FIXED 2026-08-21 — apply/switch now materialize bindings BEFORE writing the lock; materialize_bindings itself is all-or-nothing (validate the whole generation, then point). New regression test.
(profile.rs:503-505 apply, 617-618 switch) If materialize_bindings
fails midway (a pinned descriptor missing after partial GC), profile.lock
already points at the new generation while bindings point at mixed digests.
Recoverable, but ordering is backwards.
Fix: validate/materialize first; write lock last (or write-lock-temp +
rename only after success).
T3 · P0 · doc drift — AGENTS.md declares itself source of truth but lags
Status: FIXED 2026-08-21 — decision #4/#10 updated, layout table gains gc.rs/profile.rs/inspect.rs, installed/-retirement prose corrected in three spots, store-layout section refreshed, stale
{primary}/[hook] mentions fixed.
- Locked decision #4 "Dotfiles out of scope" vs shipped
[[files]]/[[units]]home-management (Phase B/C landed). - "
install/buildrecord provenance…installed/is the single manifest" vsinstalled.rs: installed/ RETIRED; provenance lives in generation manifests. - Repo layout table lacks
gc.rs,profile.rs,inspect.rs. Anyone trusting the hub doc (including future-you and agents) gets misled. Fix: one reconciliation commit.
T4 · P0.5 · no doctor; lazy dep discovery; userns restrictions
Status: FIXED 2026-08-21 —
bubblebox doctorlanded (dep binaries, userns probe with the Ubuntu 24.04 AppArmor hint, FUSE-server check, per-distro install hints from /etc/os-release; non-zero exit when a required piece is missing). Verified on the live host.
Host deps (bwrap, fusermount3, composefs-info, util-linux mountpoint,
git, podman) fail lazily — decent messages, but onboarding means hitting
them one at a time. Newer Ubuntu (24.04+, AppArmor) and openSUSE restrict
unprivileged userns; the composed-overlay path fails there opaquely.
Fix: bubblebox doctor pre-flights all deps + userns + FUSE, with
per-distro install hints.
T5 · P2 · robustness nits (network paths)
Status: LANDED 2026-08-21 —
fetch_filestreams to a.parttemp + rename (an interrupted download can no longer leave truncated bytes at a content-addressed location) with 3-attempt capped backoff (404 fails fast); gc now reports + reclaims stale.cfs.staging/.partpartial downloads. Verified with a real 1,274-blob pull.
fetch_filebuffers whole blobs in memory (cache.rs:528-534), ×16 concurrency. Stream to disk as insurance.- No retry/backoff; one transient blob failure aborts the whole package pull mid-stream.
- Failed pulls leak
.pkg.cfs.stagingforever (gc ignores non-.cfs). (left as-is — two cheap parses, clarity beats micro-caching here).applycallsload_effectivetwice per root (profile.rs:401,438)push --stage-dirre-copies even when the staged tree already has the blob (hard-link falls back to full copy; a size/mtime check makes re-staging incremental). Deferred — stage-dir is a test/self-host path.
T6 · P2 · security posture (documented, restate for distribution)
Status: FIXED 2026-08-27 (E1). Per-entry ed25519 signatures now cover exact index/default-entrypoint bytes.
pushsigns whenBUBBLEBOX_SIGNING_KEY(a 32-byte hex seed) is set; a cache's optional hexpublic_keymakes valid signatures mandatory on pull.channel.toml, registered caches, and profile caches carry the key. Verified with a real staged signed cache + isolated install. Nothing public may be served until its cache key is pinned in the published source channel.
Descriptors remain verified via measure-file and blobs are transitively trusted;
configured signed caches no longer trust their index/default-entrypoint metadata
to HTTPS alone. Per-blob content verification remains deferred.
fuse-mount pins a composefs-rs git rev — fine under Cargo.lock; revisit
pin-shape if shipping binaries.
bubblebox-pkgs
Containerfiles are exemplary — comments explain why (wezterm: COPR nightly
vs stable's Wayland bug, rpm-vs-cargo tradeoff; terraform: why not distroless;
nvim: tools composed not vendored). The /persist XDG-isolation pattern and
virtual entrypoints (age-keygen over age) are elegant.
- K1 · P2
nvim/rust-devpinned tofedora:44(landed in bubblebox-pkgs 2026-08-21). - K2 · P2
bubblebox listnow prints the closure's deduplicated store footprint (shared blobs counted once; landed 2026-08-21). Disk math remains the design's tax: 3.3 GB / 23 packages (~140 MB avg — each Fedora-base image carries its own glibc/userspace). Cross-package dedup only fires on byte-identical files, rare across bases. Accept explicitly (buys conflict-freedom) and surface aggregate footprint inlist/infoso users see it.
Part 2 — UX review vs. nix + flakes
Flow-by-flow, assuming installation is solved:
| Task | nix (flakes era) | bubblebox today | Verdict |
|---|---|---|---|
| Add tool to system | edit flake/home.nix + rebuild | add to packages array + apply |
Tie or better |
| Update everything | nix flake update && switch |
apply --refresh |
Parity |
| Rollback | boot gen / switch-to-config | generations switch <prefix> |
Parity |
| Pin selectively | per-input pins | nothing yet ([pins], tags deferred); refresh bumps everything |
Gap, acknowledged |
| Per-project env | flake.nix devShells + lock |
bubblebox.toml + base stacking |
Nicer syntax, weaker repro — no project lockfile, closure must pre-exist |
| Temp shell w/ random pkg | nix shell nixpkgs#cowsay |
impossible unless authored/cached | The gap |
| Configure software | home-manager modules | [[files]]/[[units]] symlinks |
Good enough for self-hosting |
Genuinely better than nix: no daemon/root/store-permission weirdness; readable
TOML everywhere; silent-by-default output with -v; the sandbox model (bwrap +
entrypoint TOML co-located with the recipe) is easier to reason about than
derivation outputs. The [[files]] store-symlink approach is a legitimately
simpler home-manager.
The three things that matter
U1 · P0.5 · Installation (the blocker). Today's bootstrap: clone two repos
→ rustup → cargo build inside a toolbox (no host linker) → bubblebox init
(podman + network container build) → author profile.toml → apply. Six host
deps discovered lazily along the way.
Status: LANDED 2026-08-21 (local form) —
scripts/build-portable.sh(bookworm glibc-2.36 workspace build → self-contained tarball, verified end-to-end incl. a real sandboxed run from the installed layout),scripts/install.sh(→ ~/.local/bin + doctor gate),bubblebox doctor, README quickstart with userns notes, and a beside-the-exe FUSE-binary fallback incommon.rsso installed layouts resolve. Remaining: CI calling the same script once the repo has a remote.
- Wire up the portable build that already exists:
init.rsproves glibc-2.36 bookworm builds work — extend to the CLI, ship both binaries from CI (tarball:bubblebox,bubblebox-fuse,bin/bubblebox-enter-shell,bin/bubblebox-host-shell). install.sh: drops the above into~/.local/bin, pre-flights deps with per-distro hints (dnf install bubblewrap fuse3 composefs-cli …).- README.md for the engine repo — there isn't one; AGENTS.md is agent-facing prose. 30-line quickstart (prereqs → install → profile.toml → apply).
- Document the userns requirement (Ubuntu 24.04 AppArmor, openSUSE) + doctor check.
U2 · P1 · Ecosystem gap — import is the cheap lever. nixpkgs ≈ 100k
packages; bubblebox-pkgs has 25 hand-authored ones. Getting a tool that isn't
in your repo means authoring a Containerfile.
Status: LANDED 2026-08-21 —
bubblebox import <oci-ref>(--name, --binary, --push): podman pull → shared build_descriptor pipeline → synthesized default entrypoint with the binary path PROBED from the image (non-usr-merged images like alpine keep /bin/sh; absolutebinaryvalues are now honored verbatim by run). Verified e2e (alpine:3.20, both probe-miss and explicit-binary paths). Known limitation recorded as gotcha #8: re-imports of the same image yield new descriptor digests (export metadata not normalized); blobs dedupe regardless.
bubblebox import <oci-ref>(e.g.import docker.io/library/postgres:16) → reuse the podman/create/export/mkcomposefs pipeline (build_descriptor), synthesize{package = "<name>"}, publish descriptor, optional push. Collapses "impossible" to "one command" for everything that ships an image.- Make the share-path real:
push --entrypoint+install <url>already work end-to-end; add the signed index (T6) + documented public-cache recipe.
U3 · P1 · Devshells need transient pull-on-entry. nix develop builds
what's missing; bubblebox shell errors until the closure is applied/built —
on a fresh project clone, first contact is a failure.
Status: LANDED 2026-08-21 (Phase 4.5) —
shellresolves + pulls missing closure members on entry (exact-digest fetch honoringbubblebox.lock, index-resolve otherwise; diverged pins warn + proceed,--refreshre-pins,--no-pullopts out). Project lockfile[package] name = digestrewritten on every successful entry. Verified e2e: fresh-store entry via HTTP stage cache, cache-loss failure, recovery, --no-pull refusal. Fixed a latent first-ever-entry bug along the way (ep file written before run_dir existed).
- Land designed-deferred Phase 4.5: resolve + pull missing closure members
on entry (same
pull_packagesmachinery, plus an exact-digestcache::pull_digestfor pin honoring). - Per-project
bubblebox.lockfor cross-host repro parity.
Priorities
| P | Item | Why |
|---|---|---|
| 0 | T1 ensure_parent_dir symlink deletion (home.rs:314) |
Only genuine data-loss bug found |
| 0 | T3 reconcile AGENTS.md (decision #4, installed/, layout) | Declares itself source of truth |
| 0.5 | U1 release binaries + install.sh + doctor + README |
Stated #1 goal; unreachable without it |
| 1 | U2 import <oci-ref> |
Collapses empty-ecosystem problem |
| 1 | U3 devshell transient pull-on-entry (+ project lockfile) | Makes shell ≈ nix develop |
| 2 | T5 streaming fetch/retry/staging cleanup; [pins]/tags; T6 signed index; K1/K2 footprint display + pinned bases |
Polish |
Ecosystem design — engine work items (2026-08-26)
Design locked in ~/Code/bubblebox-pkgs/DESIGN-ecosystem.md (decisions: one
public repo, three layers — curated bare names / recorded imports / distro-
wrapper behind fedora--style flat prefixes; per-entry ed25519 index signing;
~100-tool first campaign; CI = plain tools/verify.sh on bees now, forgejo/
GHA lanes later). Items it forces on the engine — these take precedence over
the 2026-08-21 priorities table above where they overlap:
| ID | P | Item |
|---|---|---|
| E1 | P0 (pre-public-bits) | Signed index: per-entry ed25519 sigs over index/<name> bytes + the served default entrypoint; sign-at-push (BUBBLEBOX_SIGNING_KEY), verify-on-pull when the cache entry carries public_key (channel.toml/profile); missing/mismatched sig = hard-fail that cache (nix-substituter skip semantics). Blobs/descriptors stay unsigned (content-addressed, pinned by signed index + measure-file). Tests: roundtrip, tamper, missing-sig. (Upgrades T6.) |
| E2 | P1 | source search / source list-packages + optional <pkg>/metadata.toml (description/homepage/license/redistribution) — public-repo discoverability; the metadata card is dead weight until something reads it. ✅ LANDED 2026-09-22 — source packages [<source>] + source search <term> (both --json); consumes the full metadata card; flags Containerfile packages missing one. |
| E3 | P1 | info --json (consider list --json too) — machine-readable footprint for the CI gate. ✅ LANDED 2026-08-26 — both info --json and list --json (footprint blobs/bytes, origin, role, mount); consumed by bubblebox-pkgs tools/verify.sh. |
| E4 | P2 | import --manifest imports.toml: batch import/refresh + drift report (recorded digest vs live index). |
| E5 | P2 (experiment) | Distro wrapper: distro build --spec distro/fedora.toml [--only <t>...] — N same-vintage per-tool images from one pinned base (NOT one fat image), fedora-<tool> naming, curated-collision refusal, NEVRA lock (distro/fedora.lock), synthesized entrypoints. Behind the measurement gate (30 tools: avg marginal < ~40 MB/tool, ≥80% headless-runnable) — DESIGN-ecosystem.md §7. |
| E6 | — | Tags name:tag: stays deferred (DESIGN-gc §7). The ecosystem namespace is deliberately flat (fedora-htop) so it can never collide with tag syntax. Note only. |
| E7 | — | multi-arch: deferred; aarch64 demand is a post-publish signal, portable build is x86_64 glibc-2.36 today. |
Roadmap (agreed direction, 2026-08-21)
Rough sequence, in the maintainer's words:
- Triage this review into concrete plans/implementations, item by item, keeping what's deemed fit.
- Solve the empty-ecosystem problem: move toward a public package repo
similar to nixpkgs with tons of common packages out of the box — while
preserving the design property that anyone may still author or host their
own entire repository anywhere. Two tracks:
- Curated public repo, populated by an agent harvesting the most popular packages from existing repos.
- Experiment: distro-package-manager wrapper (wrap
dnf install/pacman -S/ apt) so people get other ecosystems "for free". Known to carry caveats; worth investigating regardless.
- Polish pass: documentation, dependency minimization, install story covering all major distros.
- Publish as a real project with marketing + documentation site.
Working notes on track 2 (both ideas converge on one primitive):
- Both reduce to bulk-generating packages from upstream sources. Build
importfirst (U2); the agent-harvested repo and the distro wrapper both become frontends over it.import <oci-ref>covers anything that ships a container image; a generalized importer can also take a distro spec. - Distro wrapper maps cleanly onto existing machinery: ephemeral
fedora/archlinux/alpinebase +dnf install/pacman -S/apk add→ export → mkcomposefs descriptor → synthesized entrypoint. Notably, packages sharing the same base + transaction share most store blobs, so composefs cross-package dedup finally pays at scale. Caveats to design around: per-package disk still real for distinct bases; entrypoint synthesis needs heuristics (which binary? which binds/env?); record installed NEVRAs for reproducibility; trust = distro repos (acceptable — people already do); namespace rule for collisions with curated packages (e.g.fedora:htopvs curatedhtop). Prior art: apx (Vanilla OS), distrobox/toolbox positioning. - Agent-harvested curated repo: quality control matters more than volume —
each package needs a sane
entrypoint.toml(binds/env), version-pinning discipline (fedora:NN, ARG pins), a template + CI validation (build every PR, smoke-run the binary), and licensing/trademark care before publishing publicly. - Once others pull from your cache, the signed index (T6) stops being P2.
Review performed 2026-08-21 against bubblebox@8d836d0 and
bubblebox-pkgs@2f943f1.