2026-09-27 · design; owner decisions D1–D6 answered; the MUST + SHOULD probes of §5 were run on a test instance (2026-09-27) · English twin of design-app-persistence.zh.md (the Chinese file is authoritative; this one is a faithful condensation). Review method: four independent proposals (persistent overlay / chroot app system / Nix on the PVC / manifest replay) scored by three independent judges on one rubric, with every factual claim checked. Public-repo rule: zero cluster detail — placeholders only (<registry>, <domain>, <storage-class>).
Recommendation: one product-owned package manifest + a local .deb cache + a one-click human gate as the spine (every pod, no image or chart change), and on pods that have the mount capability a persistent "app system" — a small same-release Linux userland on the PVC where apt installs land for good: zero replay at boot, untouched by image upgrades. The user tells their agent "install GIMP"; the agent can only PROPOSE; the user sees "Install GIMP? 612 MB" in the For you tray, clicks Install, the log streams, the app appears in Desktop apps and the agent may open it by id. After a pod recreation: a pod with an app system reinstalls NOTHING; a pod without the capability degrades to "server up first, then an offline reinstall from the cache in the background, rows read restoring…". Data was never at risk: HOME is the PVC.
The SteamDeck-style "immutable image + persistent overlay upper" is unanimously NOT recommended as the source of truth: it is the only design that can make a pod fail to boot (DNS/sudo broken by user data), it ties every image rebuild to a fleet-wide reinstall, and it sits at the kernel's overlay stacking-depth limit; its one advantage (raw sudo apt persists) is exactly what nobody wants — nothing knows what was installed. Its operational lessons are absorbed (§3.7). Nix is the least-privilege runner-up but a second ecosystem per user (2–3 GB per big app, GL/fonts wiring); not built, kept as an optional no-capability tier for CLI tools later.
- The only thing that survives a pod recreation is the HOME PVC (RWO, 100Gi thin by default,
Recreatestrategy). The container rootfs — /usr, /etc, /opt, /var/lib/dpkg, everythingsudo aptinstalled — is lost every time. - VibeSpace is baked into the image and copied into the PVC at first boot; it runs and self-updates from
~/vibespace(deploy/docker/Dockerfile,entrypoint.sh). - The container user is uid 1000 with passwordless sudo (root-equivalent inside its own pod); the container starts as root and drops with
runuser. - CAP_SYS_ADMIN + AppArmor Unconfined are granted when
fuse.enabledorcephfs.monsis set (conditional; the chart defaults grant neither);/dev/fusevia a device plugin; no privileged, no loop devices, no user-namespace setting, no seccomp profile set. - The pod ALREADY performs kernel mounts from inside (
sudo -n mount -t ceph, rclone FUSE, tailscaled) —mount(2)works where SYS_ADMIN is granted: a proven fact, not a hope. - Today's only persistence convention for system packages is
~/.vibespace-init.sh, replayed synchronously at every boot before node starts (liveness allows ~5.5 min); no product code manages it. - Existing install doors: the xpra install plan (plan first →
sudo -n sh -c→ setsid-detached with a pidfile slot + flock → streamed log → re-attached, never run twice) and the plugin system's boot replay (intent indata/plugins.json, binaries on the PVC). - Agent tool pattern: static
data/bin/vibespace-*CLIs,VIBESPACE_API+vsst_token →/api/agent/*; the human gate is one For-you item + cookie-only routes (agent bearers 403). The desktop-app catalog is code-fixed and "an exec is a human's" (agents open catalog ids only). - Settled by probe P1 (2026-09-27, measured on a test instance's pod; every fleet pod runs the same image digest):
- Base: Debian 12 (bookworm) amd64 — the repo Dockerfile's line (
node:22-bookworm-slim): Node 22, TigerVNC 1.12 + XFCE 4.18 + Debian's chromium (a real deb, no snap stub). The private deployment notes' Ubuntu 24.04 + KasmVNC + Node 20 + git-clone-at-first-boot was an early design that never shipped; the first boot seeds the app offline withcp -afrom the image. The fleet container user is renamed to the instance name at boot (/home/vibestays as a compat symlink). - xpra is NOT in the image the fleet runs today (nor xdotool, Xvfb): the image predates the Dockerfile's xpra line — the desktop-app xpra rung on fleet pods needs a new image or the in-product "Install xpra" (onto the ephemeral rootfs — exactly what Layer 0 exists for).
uvis not in the image (theuv toolkind of §3 first installs uv into~/.local/bin).- Privileges:
capabilities.add: [SYS_ADMIN, NET_ADMIN], AppArmor Unconfined, no seccomp filter at all (Seccomp: 0), NoNewPrivs 0; bounding set = the runtime's default 14 + those two, SYS_CHROOT included, SYS_PTRACE absent (root cannot read a uid-1000 process's smaps/environ; a setuid bwrap cannot work). Every fleet pod has them (fuse and cephfs are both on in production). - HOME: ext4 on a block PVC,
rw,relatime(no nosuid/noexec); the PVC root isroot:<user> 2775through fsGroup (setgid — directories mkdir'd below it inherit the group and setgid, §5 P3)./tmplives on the container's overlay root;/dev/shmis 64 MiB. - Kernel 6.x (the node distribution's kernel);
apparmor_restrict_unprivileged_userns=1, yetunshare -Urworks for the pod user. pid 1 isrunuser(no init) and never reaps orphans (harmless zombies; the keeper is its own shim's parent and reaps it).
- Base: Debian 12 (bookworm) amd64 — the repo Dockerfile's line (
| ① overlay upper (SteamOS-style) | ② app system (chroot sysroot) | ③ Nix on the PVC | ④ manifest replay + .deb cache | |
|---|---|---|---|---|
| persistence / agent / privilege / image-update / boot / disk / GUI / ops / effort | 4 / 5 / 2 / 3 / 4–5 / 3–4 / 5 / 2 / 2 | 5 / 4–5 / 2–3 / 4–5 / 5 / 3 / 4 / 2–3 / 3 | 5 / 3–4 / 5 / 5 / 5 / 2 / 3 / 3 / 2–3 | 3 / 4–5 / 3–4 / 4 / 2 / 4–5 / 5 / 4 / 3–4 |
| judges' totals | 31 / 30 / 30 (last for all three) | 34 / 35 / 33 (first for two) | 33 / 35 / 35 | 33 / 35 / 35 (first for one, "as the spine") |
All three judges: no single road suffices; ②'s execution model on ④'s record layer beats every single approach (judges 1 and 2 explicitly; judge 3 chose ① as a disposable cache over ④, for the same reason — ④ is the source of truth). Adopted: ② + ④, with ①'s operational lessons (§3.7). Factual errors the judges caught and this document corrects: in this pod (init user namespace + SYS_ADMIN + AppArmor Unconfined) a fresh mount -t proc is NOT refused — the app system still rbinds /proc to inherit the container's masks; an overlay's workdir is written at every mount, so a full PVC yields a read-only mount or ENOSPC, never "mounts anyway"; bwrap as root needs no user namespace (measured on this box), so flatpak via a setuid bwrap is a probe, not a known failure (P16: on a fleet pod a setuid bwrap fails — no SYS_PTRACE; only a pivot_root + fresh-proc entry runs it, §5); the image ships dbus-x11, so GTK dconf writes most likely persist through dbus-launch --autolaunch; setuid effectiveness follows the mount the exec path traverses, not the PVC's nosuid.
Layer 0 — manifest, cache, gate (every pod; build first; ships by git pull). ~/.vibespace/apps/manifest.json (the server is its one writer): sources (https only, key sha256-pinned), entries (apt | uv-tool | npm | appimage; who asked, why, approved when; catalog rows; services), resolved versions. ~/.vibespace/apps/debs/ = a sha256-indexed local file: repository (.deb + stanza + Packages). Two rules measured by P15: Keep-Downloaded-Packages keeps only what apt fetched THIS time — on a drifted rootfs (the product's own xpra install) dependencies go missing, so completeness is judged against the image's base package list (closure − base ⊆ cache, topped up with apt-get download); and apt's _apt sandbox user cannot traverse ~/.vibespace (0700), so the file: rung passes -o APT::Sandbox::User=root (or uses a path _apt can reach). ONE package slot per machine (the xpra INSTALL_LAUNCHER/RUNNER generalised; flock; xpra install, app install/remove/refresh and the boot replay all take it; every apt call passes DPkg::Lock::Timeout=300). The plan is PURE and runs nothing as root before approval (apt-get -s + --print-uris as the user → closure, sizes, new sources, fingerprints; refusals by name: needs_snap, conflict (the WHOLE manifest is simulated), bad_name, bad_source, no_sudo, no_apt). The install runs the approved script detached through the slot with names as positional argv; the server parses = delta / = desktop / = service lines into the manifest and catalog rows (app.<entry>, .desktop Name/Exec/Icon, field codes dropped, validated). Agent flow: vibespace-app search|install|remove|status|wait|docs (all pre-approved — none executes anything; add --kind uv|npm|appimage is HELD like vibespace-job run); the outcome reaches the agent free on its next turn. Human gate: ONE For-you item (new origin apps) → the same dialog component as "Install xpra on {machine}…" (commands, packages, new sources + fingerprints, sizes, "after a pod restart: …") → Install streams the log. Approving an install approves its .desktop rows, so vibespace-window open app.gimp is allowed (the "an exec is a human's" rule stays for arbitrary executables). Who installs (owner D3, 2026-09-27): installing an app is not a frequent need, so ordinary sessions get ZERO new injected teaching text — vibespace-app is just a binary on PATH and its manual lives only in the on-demand vibespace-docs index (vibespace-docs apps); the primary door is the user's own "Install an app…" in the Desktop apps dialog; a "Let an agent help" button there spawns a TEMPORARY helper session through the existing new-session-with-a-prefilled-prompt path (the request + the full apps manual + the machine's facts as its first prompt; it proposes, the user approves in the same dialog, it says done, it is archived as a helper). Approved catalog rows are openable by id by every agent (no context cost — vibespace-window already exists). Manual .deb (owner D4): "Install from a .deb file…" — plan = dpkg-deb -I + apt-get -s install ./file.deb, the same gate, apt-get install ./file.deb, the file copied into the local repository with its sha256 so replay/Rebase can reinstall it. A drift tripwire (DPkg::Post-Invoke) labels packages installed outside VibeSpace "lost at the next pod restart — [Adopt]"; ~/.vibespace-init.sh remains the escape hatch with a one-time "move these apt lines to Apps" offer. Free-space floor: refuse below installed + 2 GiB.
Layer 1 — the app system (pods with SYS_ADMIN; the recommended persistent tier). ~/.vibespace/sysroot/rootfs/ (root:root, a same-release minbase with its own /usr /etc /var/lib/dpkg), an identity file (codename/id/arch/createdFrom/helperContract), user-owned exported launchers in ~/.vibespace/sysroot/bin/ appended to the END of PATH. Two small files are re-installed onto the ephemeral rootfs at every boot (sudo -n install, the /sbin/modprobe shim precedent; only when the chart sets VIBESPACE_APP_SYSTEM): the helper vs-sysroot-enter and a sudoers drop-in (visudo -cf before install) that grants NOTHING new — it makes sudo direct-exec the helper on the same pid with the X env kept. The helper: derive HOME from SUDO_UID (never from the environment), refuse unless realpath(R)==R, root-owned (uid 0 and not world-writable; the group is NOT required to be root — fsGroup rewrites it), identity present, arch matches (reference implementation: deploy/sysroot/vs-sysroot-enter, the one that passed the §5 probes); unshare --mount --propagation private; rbind /proc /sys(ro) /dev /home /tmp /run/user/$UID, ro-bind /etc/{resolv.conf,hosts,hostname,localtime} and the image's /usr/share/fonts; upsert the user's passwd/group line; chroot --userspec + env -i allowlist (no session token, no host ~/.local/bin); --root only for apt/dpkg, only by the server. The namespace dies with its last process — nothing to unmount, no stale mounts. A shim is exec sudo -n <helper> --cwd "$PWD" -- /usr/bin/gimp "$@"; every hop is an exec on the keeper's pid, so handle / starttime / group kill / marker / PSS report are unchanged (probe P7 must prove the direct exec). Boot: nothing before listen; after listen ≈ 0.3–0.6 s in child processes (the two files — measured 35 ms, identity + manifest, shim regeneration, dpkg --audit → "An install was interrupted — Repair"). Creation once, on a human click: rung A (a minbase tarball baked into the image, 15–60 s offline) or rung B (debootstrap, measured 23 s / 206 MB; the target directory is created root:root 0755 with setgid cleared first, or fsGroup's group + setgid leak into the whole userland — §5 P3). Each app launch pays ≈ 75 ms (measured, the bash reference: 47 external commands + the rbinds; a compiled helper would reach 20–40 ms). Image upgrades: the two package worlds never share a dpkg database, so "dpkg state over a changed base" cannot happen; a release change keeps the old app system running (X11/GLX are wire protocols) and shows "Rebase…" (build rootfs.next, replay the manifest, verify exports, swap, keep rootfs.prev for Roll back); an arch change refuses + Rebase. Security updates no longer come from the image: a per-user "N updates · last refreshed N days ago" chip + Refresh, and a per-pod fleet lever for the operator (runuser -u <u> -- vibespace-app refresh --all) — the design's biggest ops gap, stated. GUI: launched like any registry exec (DISPLAY/XAUTHORITY through env_keep + the allowlist, X via the /tmp rbind, GL = the app system's own llvmpipe, fonts via the ro-bind, dbus via autolaunch).
Layer 2 — replay (pods without the mount capability; the same manifest). After listen: read the manifest → read the ephemeral marker /var/lib/vibespace/apps-replayed (a server restart hits it in 1 ms) → on a fresh rootfs launch the detached runner through the slot: restore sources/keys/pins → rung 1 OFFLINE from the local repository (measured by P15: GIMP = 117 packages / 92 MB / 397 MB in 8.9 s offline, ~1 s for a few small packages — dpkg unpacking dominates, the same set online took 9.9 s) → rung 2 online only when the base moved / rung 1 failed / Refresh → failures named per entry (state.json + one notice), never blocking others. Rows read restoring… meanwhile; jobs marked after: apps wait. A pod killed mid-replay is harmless (a fresh rootfs next time).
User-level kinds (no replay in either layer): uv tool (uv-managed Python), npm --prefix ~/.local (rebuilt on a node ABI change), AppImages extracted once into ~/.local/opt/<id>. Recorded in the manifest; HELD CLI card only, no For-you approval (they grant nothing the agent's Bash lacks).
Service packages (v1 boundary): postinst completes without systemd, nothing restarts them; the manifest records units; the user runs them as vibespace-job run --keep-up marked after: apps; a "persist these paths" field is v2.
Catalog: DEFAULT_REGISTRY ∪ manifest rows (app.<entry> / sys.<name>); icons through a cookie-only route (img.src, nosniff); greyed rows carry their reason (restoring / unavailable: needs the pod's mount permission / interrupted — Repair); an "installed outside VibeSpace" column with Adopt.
§3.7 Rules absorbed from the overlay proposal (true for any persistent tier on a boot path): bake an image stamp (/usr/share/vibespace/image.json + base-packages.tsv) so "the base moved" is a fact; boot-path scripts exec from the image or the checkout, never from the PVC; anything before listen needs a smoke test + attempt counter + safe mode (this design puts everything after listen); anything covering /etc must save-and-rebind resolv.conf/hosts/hostname; a PVC restore may trip fsGroup's OnRootMismatch walk (gid + group bits change, uid stays, setuid re-applied) — the helper judges by the identity file, not root ownership, and repairs drift with --reinstall from the cache.
Overlay as the source of truth: no (boot-failure class, fleet-wide reinstall per image, stacking-depth EINVAL, xattr-sensitive backups). Overlay as a disposable cache: not v1 (the app system already has zero replay). Nix: not v1, optional later for CLI tools on capability-less pods. flatpak: probe P16 decides (setuid bwrap). Paired Linux machines get the manifest + catalog rows for free (their rootfs persists; no app system needed).
§5 Probes P0 (on a TEST instance's pod, one day, before any code) — pass criteria in the Chinese file §5; results measured 2026-09-27
Results: P1 PASS (facts in §1; only the fleet chart profile had a pod to test) · P2 PASS (OK, nothing leaked; a fresh proc/sysfs mount also works) · P3 PASS (uid 1000 as the renamed user, HOME + compat symlink, resolv.conf, fonts, cwd kept; refusals: no SUDO_UID / direct stage 2 / arch mismatch; debootstrap 23 s / 206 MB / 6,768 inodes; finding: fsGroup's setgid leaked the user's group + setgid into 110 dirs → pre-create root:root 0755, setgid cleared; minbase has a symlinked etc/localtime and no etc/hosts → the helper replaces/creates bind targets) · P4 PASS (ext4 rw,relatime; xterm + mesa-utils + hello = 70 pkgs / 57 MB / 231 MB in 6.8 s; su stays 4755 and is effective; the app system's apt keeps its .debs) · P5 PASS (delete → Ready 19 s incl. a 6 s RWO attach wait as the pod moved nodes; Started → Ready = the readiness probe's 10 s initial delay; helper + drop-in re-install 35 ms, hello first launch 99 ms; zero apt, no mirror connection; an app-system xterm opened on the image's own Xtigervnc) · P6 PASS through xpra directly, not through the keeper (the test instance predates desktop apps): xpra 6.5.4 installed ephemerally (the fleet image has none); the xterm is xpra's direct child (no sudo between); xcalc mapped; typed into the xterm through an attached client and the command ran (its file appeared in the pod's /tmp — the rbind works); 30 Noto CJK faces through the font bind; llvmpipe GL 4.5; xpra stop ended both within 3.8 s; notes: xpra 6.5 refuses --bind-tcp=…:0, windows stay unviewable until a client attaches · P7 PASS (the setsid pid itself is the app, Uid 1000 ×4, exe in the app system, smaps readable by the user; control without the drop-in: the leader is a root sudo with a child and the user gets EACCES — the drop-in is load-bearing; the keeper needs no child tracking) · P8 PASS (DISPLAY/XAUTHORITY/VIBESPACE_DESKTOP_APP present, no session token) · P12 (debootstrap 23 s / 206 MB; launch overhead ≈ 75 ms; loop-gap not measurable on that instance) · P14 PASS (dconf write survives a fresh X + a fresh autolaunched bus; the minbase has no /etc/machine-id, dbus's postinst makes /var/lib/dbus/machine-id) · P15 PASS after two fixes (a custom Dir::Cache::archives survives the docker-clean hook; _apt sandbox → APT::Sandbox::User=root; 5 packages missing because the old rootfs had drifted → completeness against the base; then 117 pkgs / 92 MB / 397 MB offline in 8.9 s, zero http) · P16 FAIL as designed, a working variant found (in the chroot the kernel refuses a user namespace; setuid bwrap fails at capset — no SYS_PTRACE; host-side bwrap --proc fails on the masked container /proc; a pivot_root entry + a FRESH /proc makes unprivileged bwrap --proc --unshare-all work → flatpak needs that entry, at the cost of the container's /proc masks inside the app system — an owner decision) · P17 recorded (unshare -Ur works for the user on the host side, refused inside the chroot) · P9 P10 P11 P13 not run.
P1 base + capabilities per chart profile · P2 private mount namespace + binds (nothing leaks) · P3 helper enter on a scratch rootfs · P4 the PVC as a root filesystem (not noexec, setuid kept) · P5 survives a pod deletion with zero apt/network · P6 GUI under xpra (CJK fonts, llvmpipe, Stop) · P7 sudo direct exec (leader pid uid 1000) · P8 env across sudo (no session token) · P9 fsGroup ownership after restarts · P10 mounts made before/after launch · P11 pod killed mid-install → Repair · P12 sizes/times/loop-gap · P13 an Electron .deb · P14 dconf persists · P15 Layer 2 offline replay + 50-package timing · P16 flatpak via setuid bwrap · P17 user namespaces (informational).
P0 probes 1 d → P1 Layer 0 (PURE src/app-manifest.js, the machine half in desktop-serve.js with the generalised package slot, ORCH replay/routes/For-you origin apps/Refresh, data/bin/vibespace-app + docs/agent/apps-manual.md, dialog, zh/ja; gates test-app-manifest fast + test-app-install heavy + architecture censuses) 10–13 d → P2 Layer 1 (helper + sudoers + boot reinstall gated on VIBESPACE_APP_SYSTEM, rungs A/B, PURE src/app-system.js, keeper install/remove/update/repair/rebase on the same slot, sys. rows, banners; gates test-app-system fast + test-app-system-enter heavy) 12–13 d → P3 ops (image stamp + base-packages.tsv, chart appSystem.enabled forcing the SYS_ADMIN branch, fleet refresh script, runbook — private deployment repo) 1–2 d. P1 alone is valuable (every pod persists, with a replay); P2 makes the fleet zero-replay.
§7 Owner decisions — answered 2026-09-27: D1 yes · D2 yes · D3 revised (no extra injected context for ordinary sessions; a user-driven install entry + a temporary full-context helper agent, §3) · D4 yes, incl. manual .deb · D5 yes · D6 yes (run on the test instance; the MUST + SHOULD probes plus P14/P16/P17 ran 2026-09-27 — results in §5)
D1 the persistent tier is the app system (chroot), not an overlay cache · D2 every fleet pod may have SYS_ADMIN + AppArmor Unconfined, as an explicit chart switch appSystem.enabled · D3 agent proposes → one-click approval → install, and approval turns the app's .desktop into catalog rows the agent may open by id · D4 third-party apt sources allowed, each proposed separately with URL + key fingerprint · D5 security updates = a per-user chip + Refresh (no auto-upgrade), plus an operator script · D6 run the P0 probes on the test instance's pod.
Two userlands confuse people (terminal sudo apt is ephemeral, vibespace-app persists; an IDE in the app system sees its own toolchain) — the tripwire + Adopt + the first line of the agent manual. Patch ownership moves to the user (§3). The app system shares the PVC with data/. Layer 2's boot latency exists only on capability-less pods and only after listen. The kernel is shared (no DKMS/modules) and there is no GPU (GL = llvmpipe).
-
The pin of an approved third-party source (2.369.203, verify-r1 H1):
/etc/apt/preferences.d/vibespace-<id>.prefper source (root's copy in~/.vibespace/apps/sys/sources/<id>.pref): everything from the source's host (Pin: origin "<host>", no port) at priority 1 — taken only when no version of that package is installed — and the packages the user installed FROM it (attributed before apt runs with--print-uris, kept insys/entries/<id>.pin) at 500. Rewritten by every root run, restored before apt in both replay rungs; the manifest'ssources[].pin.packagesmirrors it; the Refresh card names each update's origin. Measured in Debian 12 containers against a signed https fixture source (details in the Chinese file §9). Every root run re-pins before its first apt-get; source mode writes the pin before the source goes live; a source on a host the machine's own apt sources use is refused (shared-host), becausePin: originmatches a host and would pin the machine's own archive too. -
One read of a .deb / key (H2) and a boot replay waits out a busy slot (H3) — see the Chinese file §9.
-
Closing Layer 1 (2026-10-19, lane app-persist-layer1): the fleet's Refresh lever (D5) —
runuser -u <user> -- <checkout>/data/bin/vibespace-app refresh --all, no session token: this account's unix socket~/.vibespace/apps/operator/ops.sock(0600 in a 0700 dir, opened after listen, src/server/apps-ops-door.js) into the SAMEengine.run({kind:'refresh'})as the user's press, through the one package slot (busy ⇒ exit 75; refused inside a conversation; the same uid is no boundary — said plainly, the pod's account already holds NOPASSWD sudo). Fast gates without root: test-app-system-chain (the real helper over recorder fakes — the argv table, 19 refusals before any namespace op, pid P under the real keeper, the boot order's loop gaps) + test-apps-ops-door. Real legs for the test pod: the lane's real-leg.md. Details: the Chinese file §9.
As built — Layer 1, the app system (2.369.210, lane app-system-l1): PURE src/app-system.js (identity, rebase verdict, systemView = GET /api/apps appSystem, rows + the export table, the versioned shim, SYS_SCRIPT, INSTALL_SCRIPT, SUDOERS_TEXT) + the machine half src/app-system-serve.js; the helper deploy/sysroot/vs-sysroot-enter + deploy/sysroot/vibespace-sysroot.sudoers, re-installed after listen only with VIBESPACE_APP_SYSTEM. Differences from the text: root's entry records live INSIDE the userland (rootfs/var/lib/vibespace/entries), CLI executables are exported too, the helper checks a contract number and runs only apt/dpkg as root, Roll back swaps rootfs ⇄ rootfs.prev. Details and measurements: the zh document's §9.