# VFS Deft's **virtual filesystem layer** sits behind the `fs/` namespace and lets ordinary file operations route to different backends — another directory, a remote host over ssh — based on the *leading path segment* of the path. It is modelled on TCL's VFS, adapted to Deft's conventions: mounts are declared as data (in the prelude, like everything else project-level), and call sites stay ordinary Deft strings.
 [vfs/mount "prod" %{ :kind :ssh :host "10.0.0.5" :root "/srv/app" }]

[fs/read  "prod/etc/hostname"]     # read /srv/app/etc/hostname on 10.0.0.5
[fs/write "prod/config.toml" "..."]
[fs/list  "prod"]                  # bare mount name = the mount root
[fs/read  "/etc/hostname"]         # no mount named → native, as always 
There is no new path syntax to learn: a path whose first segment matches a mount name routes to that mount's driver; every other path behaves exactly as before. ## Declaring Mounts ### In the prelude The `:mounts` map in `.deft/prelude.dft` is the idiomatic home for mounts. **Value type decides** what a mount means: - **string value** — a classic import mount: `import "/..."` resolves through the directory. Never intercepted by `fs/`. - **map or `%Local{}`/`%Ssh{}` value** — a VFS mount: `fs/*` paths under the name route to its driver. Never an import mount.
 %Prelude{
    :mounts %{
        :scripts "scripts"                                   # import mount
        :docs    %Local{ :root "/usr/share/doc" }            # VFS: local dir
        :prod    %{ :kind :ssh :host "10.0.0.5" :root "/srv/app"
                    :user "root" :port 22 } } } 
Prelude mounts are seeded at startup into a process-global table, so every app thread sees them — including child runtimes that skip the prelude load. ### At runtime
 [vfs/mount "prod" %{ :kind :ssh :host "10.0.0.5" :root "/srv/app" }]
[vfs/unmount "prod"]
[vfs/list]        # → %{ :prod %{ :kind "ssh" :host ... :root ... } } 
`vfs/mount` publishes on the `"vfs"` pub/sub topic (`%{ :name :value :old }` under `:data`), mirroring the `config/*` reactivity pattern. Runtime mounts additionally write through to the shared config registry under `vfs.mount.` for visibility tooling. ## Mount Specs | Field | Local | ssh | | |---|---|---|---| | `:kind` | `:local` | `:ssh` | required (or use `%Local{}`/`%Ssh{}`) | | `:root` | local anchor dir | remote anchor dir | required for local | | `:host` | — | hostname or `user@host` | required for ssh | | `:user` | — | ssh user | optional | | `:port` | — | port (default 22) | optional | | `:identity` | — | key file | optional | | `:jump` | — | jump-host (ssh `-J`) | optional | | `:options` | — | extra `-o` options, `@{"Opt=val"}` | optional | ## Path Semantics - **Longest prefix wins**: with mounts `deft` and `deft/ui`, the path `deft/ui/button.dft` routes to `deft/ui`; `deft/other/x` routes to `deft`. - **Exact segments**: `products/x` does *not* match a mount named `prod`. - **A mount is a chroot**: `..` cannot escape — `prod/../../etc` resolves *inside* the mount root. - **Bare mount names are the root**: `fs/list "prod"` lists the mount root itself. - **Predicates follow**: `fs/absolute?` and `fs/abs` treat mount-anchored paths as fully qualified (`fs/abs` is the identity); `fs/dir`, `fs/base`, `fs/ext`, `fs/join` operate on the virtual string as usual. ## What Routes and What Doesn't All whole-file `fs/*` verbs route through mounts: `fs/read`, `fs/write`, `fs/append`, `fs/read-lines`, `fs/write-lines`, `fs/stat`, `fs/list`, `fs/readdir`, `fs/glob`, `fs/exists?`, `fs/file?`, `fs/dir?`, `fs/mkdir`, `fs/remove`, `fs/rename` (same mount), `fs/copy` (across mounts too — it composes read+write). Clean `%Err` on mounts (v1): - `fs/watch` — inotify is local-only; remote watching needs polling. - `fs/link` — symlinks don't map to remote semantics. - `fs/glob` with `:recurse` — no driver walk yet. - `fs/rename` **across** two different mounts. Host-scoped by design (never virtual): `fs/tempdir`, `fs/home`, `fs/tmp-dir`, `fs/tmp-path`, `fs/local-path`, `fs/local-dir`. The `sqlite/*` namespace reads through its own vendored engine and does not route through mounts — materialize a remote file to a local temp path first if you need to open a remote database. ## The ssh Backend The `:ssh` backend shells out to the system `ssh` client, one process per operation. Everything you already know about ssh applies for free: keys and agent auth, `~/.ssh/config` aliases, `known_hosts`, jump hosts, `ControlMaster` connection reuse. Defaults `-o BatchMode=yes -o ConnectTimeout=10` are applied *before* `:options`, so a mount can override either (ssh honours the last `-o`). BatchMode means keys or agent only — an interactive password prompt would fail fast rather than hang a script, which is the right behavior for a headless runtime. The remote side needs nothing but a POSIX shell with GNU or BusyBox `stat` and `ls` (any typical Linux or BSD userland). File content travels on `cat`'s stdin/stdout — no temp files — and remote paths are shell-quoted, so spaces and metacharacters are safe. Because every operation spawns a process, per-call latency is ssh-round-trip scale. Fine for config files, logs, and deploy scripts; a persistent sftp session is the planned upgrade behind the same interface. ## Sandbox Note An ssh mount needs subprocess and network access, which the Landlock sandbox (`DEFT_SANDBOX=1`) denies. The prelude loader warns when an ssh mount is declared in a sandboxed session. ## Examples ### Deploy script
 [vfs/mount "prod" %{ :kind :ssh :host "prod.example" :root "/srv/app" }]

def local-hash [fs/read "build/SHA"]
def remote-hash [fs/read "prod/SHA"]
if ($local-hash != $remote-hash) {
    [fs/write "prod/SHA" $local-hash]
    [fs/copy "build/app.tar.gz" "prod/app.tar.gz"]
} 
### Reading a doc directory under a short name
 [vfs/mount "docs" %Local{ :root "/usr/share/doc" }]
[fs/glob "docs" "*.md"] 
## See Also - [Prelude](prelude) — `:mounts`, where declarative mounts live - [Config](config) — the registry pattern `vfs/*` reactivity follows - [Sandbox](sandbox) — Landlock interplay - Reference: `vfs/mount`, `vfs/unmount`, `vfs/list`