learn / 09·2 — under vfs · volumes

three lifetimesONEdisk

The parent lesson sold you one disk in the file. Here's its geography. A volume isn't a folder or a mount — it's one column of a two-column key. Three names, three lifetime contracts: workspace persists and travels, memory persists but stays home, tmp evaporates on the next wake. The whole policy is two one-line SQL statements.

volumes9 min read
A lone figure standing before three monumental bright storage vaults rising into a skylit hall — one vault doors open and packed for travel, one sealed and rooted to the floor, the third dissolving into light as a sweeper clears it — 1970s sci-fi style, bold and luminous

three lifetimes, one DISK

The parent lesson told you the workbook carries its own disk — real paths, riding in the file, sealed from the host. True, and the reader's next collision is already here: lifetime confusion. An agent's scratch junk and half-thoughts sit on that disk right next to the deliverable. When you share the file — or the engine freezes an idle session overnight — what exactly survives?

On a normal operating system, that question is folklore. /tmp evaporates, except sometimes it doesn't. ~/.cache is disposable by convention. A .gitignore keeps secrets out of a push, until someone forgets a line. "Does this byte persist, travel, or disappear" is answered by tribal knowledge — a habit, a README, a thing the person who left used to know.

Here it can't be folklore, because agents make it urgent. Their scratch lives beside your work product on the same disk, and that disk is a file you can email. So the lifetime of a byte has to be a fact — something the engine enforces, not something you remember. This lesson is that fact: three named regions, three contracts, and a policy small enough to fit on a business card.

the DEFINITION

vol·ume /ˈvɒl·juːm/ noun

1. a named region of the workbook's disk, keyed not mounted — one of exactly three (workspace, memory, tmp), each carrying a lifetime contract the engine enforces: whether its bytes survive an overnight freeze, and whether they travel when you share the file.

Three is the whole set, and it's closed by construction: @volumes ~w(workspace memory tmp) in the runtime, exposed as Workbooks.VFS.volumes/0. Not "three for now" — three, with a function guard that makes a fourth name a compile-time-shaped failure, not a new feature. We'll come back to why that guard matters.

the three CONTRACTS

One region per role, and each role is a sentence about time. The whole lesson lives in this table — read it as three promises:

volumewhat it's forsurvives resume?ships on share?who writes it by default
workspacethe workbook's files — the working treeyes — persistsyes — it's the workeverything — it's the default
memoryagent long-term memoryyes — persists across freezeno — stays homereserved for agents (see honesty)
tmpscratch — half-thoughts, intermediatesno — cleared on the next wakeno — stays homecode that asks for it explicitly

The verdict in one breath: workspace persists and travels — it's the deliverable, the thing you collaborate on and the thing you ship. memory persists but never leaves — the agent's accumulated context is durable across an overnight freeze, yet a recipient of the file never sees it. tmp is the most disposable thing in the system: it doesn't survive the next wake, and it never ships. Those are not policies bolted on afterward — they fall out of two SQL statements, which the rest of this page builds toward.

a column, not a FOLDER

depth rung · skippable — the key that makes it all work

Here's the move that makes "volume" precise. A volume is not a directory, and it is not a mount point. It's the first column of a two-column primary key. The entire VFS is one SQLite table:

CREATE TABLE IF NOT EXISTS vfs (
  volume TEXT NOT NULL, path TEXT NOT NULL, content BLOB, mtime INTEGER,
  PRIMARY KEY (volume, path)
);

The key is (volume, path) — so the same path can exist in all three volumes at once, each holding different bytes. /note in workspace and /note in memory and /note in tmp are three distinct rows, three distinct contents, no collision. Picture the table not as a tree but as one store with three lanes:

flowchart LR
  q["a write: put(/note, …, volume)"]
  subgraph t["one vfs table — keyed by (volume, path)"]
    direction TB
    r1["volume=workspace · path=/note
'the workbook files'"] r2["volume=memory · path=/note
'what the agent learned'"] r3["volume=tmp · path=/note
'scratch'"] end q --> r1 q --> r2 q --> r3 style t fill:#fbfaf6,stroke:#121316 style r1 fill:#a8d4f0,stroke:#121316 style r2 fill:#aee5c2,stroke:#121316 style r3 fill:#f3c5a3,stroke:#121316 style q fill:#ffffff,stroke:#121316

Read the graph as three rows sharing one name. One put to /note in workspace, another to /note in memory, a third to /note in tmp — and the store holds all three side by side, blue, green, and clay, the same path pointing at three different strings. Every write also stamps mtime with the wall clock, so "what changed and when" is a column, not folklore.

And the set is closed by the language itself. The write function carries a guard — def put(conn, path, content, volume \\ @default) when volume in @volumes — so writing to a volume that doesn't exist isn't an error you handle. It's a function clause that has no match. The same guard sits on clear. You can't typo your way into a fourth region; there's nowhere for the call to land.

the default is the working TREE

Notice the \\ @default in that signature. Every put and get defaults to workspace — the volume is the optional last argument. Which means all ordinary code that never names a volume is writing the working tree, automatically:

put(conn, "/report.org", bytes)              → lands in workspace
put(conn, "/report.org", bytes, "workspace") → identical — the default spelled out
put(conn, "/scratch", junk, "tmp")           → the only way into tmp: ask for it

This is a quiet, deliberate design choice. The Dock's filesystem imports — the calls a component or a JavaScript or Rust toolkit makes to read and write files — pass no volume argument, so they land in workspace. An agent's own event log, /events.org, defaults to workspace. Components writing data, agents leaving notes, tools saving output: all workspace, unless they say otherwise.

So naive code does the right thing twice over. It's persistent — it survives a freeze — and it's public-shaped — it's the region that travels when you share. The dangerous default would be the reverse: scratch that accidentally ships, or work that accidentally evaporates. Here you have to opt in to a private or disposable lifetime by naming the volume; the silent path is the safe one.

what resume CLEARS

Now the lifetimes, made real. Two engine moments do all the work. The first is the freeze/resume cycle the engine runs on idle sessions.

Freeze is almost nothing: the engine copies the SQLite file to cold storage — File.cp to <session_id>.sqlite. Stop the process, keep the file. No VM snapshot. All three volumes ride along in the frozen file, untouched.

Resume is where the contract gets enforced. The engine copies the cold file back to live, opens it, and runs exactly one statement — clear(conn, "tmp"), which is DELETE FROM vfs WHERE volume = 'tmp' — then proceeds. That single DELETE is the entire "tmp is scratch" mechanism. workspace and memory persist for the most boring reason possible: nothing touches them.

sequenceDiagram
  participant A as active session
  participant C as cold storage
  participant R as resumed session
  A->>A: idle 15 min — suspend
  A->>C: idle 24 h — freeze (File.cp of the .sqlite)
  Note over C: all three volumes ride in the frozen file
  C->>R: wake — copy back, open
  R->>R: DELETE FROM vfs WHERE volume = 'tmp'
  Note over R: workspace ✓   memory ✓   tmp ✗

Follow the session through the diagram. It goes idle; after fifteen minutes it suspends; after twenty-four hours of silence the engine freezes it — a plain file copy to cold storage, every volume aboard. When something wakes it, the engine copies the file back, opens it, and runs that one DELETE against tmp. workspace and memory come back whole; tmp comes back empty. The thresholds — fifteen minutes to suspend, twenty-four hours to freeze — are the rhythm that makes tmp's lifetime concrete: a session quiet overnight wakes with its scratch gone. The full state machine belongs to the sync lesson; here, just hold onto the one DELETE.

what shipping STRIPS

The second moment is egress — the instant the disk leaves the building. The rule is stated plainly in the runtime: sharing exposes work, never the session that produced it. It's not an abstract principle; it was written after a real leak, where a task tracker's private data rode a push to GitHub.

Exactly one volume is public: @public_volumes ~w(workspace). When the engine ships a bundle, it runs public_only on the VFS bytes — write them to a temp file, DELETE FROM vfs WHERE volume NOT IN ('workspace'), then VACUUM so the deleted bytes are gone-gone, not merely unlinked and recoverable. memory and tmp don't get redacted in the shipped copy; they were never written into it.

flowchart LR
  subgraph full["the live VFS — all three volumes"]
    w["workspace"]
    m["memory"]
    tp["tmp"]
  end
  ship["ship → public_only → VACUUM"]
  subgraph bundle["the wbundle that travels"]
    w2["workspace only"]
  end
  w --> ship --> w2
  m -. "stays home" .-> full
  tp -. "stays home" .-> full
  style full fill:#fbfaf6,stroke:#121316
  style bundle fill:#fbfaf6,stroke:#121316
  style w fill:#a8d4f0,stroke:#121316
  style w2 fill:#a8d4f0,stroke:#121316,stroke-width:2.5px
  style m fill:#aee5c2,stroke:#121316
  style tp fill:#f3c5a3,stroke:#121316
  style ship fill:#ffffff,stroke:#121316

Read the flow as one arrow that makes it out and two that bend back home. The full disk has all three volumes; ship runs public_only and a VACUUM; what lands in the bundle is workspace alone. The memory and tmp arrows curve back into the live VFS — they stayed put.

And the opt-in is always legible from the outside. The bundle's manifest records what shipped:

{ "id": "ship-4821", "format": "wbundle/1",
  "volumes": ["workspace"], "signed": false,
  "private_included": false, "created": 1781136000 }

The demo that produces this wrote "carried across the wire" into the memory volume, shipped without include_private, and the restored read on the other side comes back :error — the byte stayed home, and the manifest says so in two fields a recipient can read. Flip the switch to include_private: true and "volumes" becomes ["workspace","memory","tmp"] with "private_included": true. Whether a session rode along is never a guess — it's stamped on the package.

the same boundary in GIT

depth rung · skippable — the file-form mirror

The VFS can also be unpacked to a real directory tree — and when it is, the same boundary has to hold in git's terms, not just SQLite's. So the runtime emits the private volumes as gitignore globs, generated from the very same list it uses for the SQLite strip — one source of truth, file-form and VFS-form in lockstep. Every tenant repo gets these two lines written into its .gitignore automatically:

memory/
tmp/

That's @volumes -- @public_volumes rendered as directory globs — literally the volume set minus the public one. If the disk is ever walked out to a tree and committed, git treats memory/ and tmp/ as private for the same reason the egress strip does: they aren't the work. Library checkout strips the same way, through a git check-ignore pass. The full boundary module — the prefix rules, the strip-on-checkout machinery — is the privacy lesson's territory; here the point is only that the line is drawn once and holds in both forms.

which volume does this byte go IN?

The practical question. For any byte your code is about to write, three questions in order settle it:

flowchart TD
  start["a byte to write"]
  q1{"is it the work —
a deliverable?"} q2{"is it learned context
the agent should keep?"} ws["workspace
persists · travels"] mem["memory
persists · stays home"] tmp["tmp
evaporates next wake"] start --> q1 q1 -- yes --> ws q1 -- no --> q2 q2 -- yes --> mem q2 -- no --> tmp style ws fill:#a8d4f0,stroke:#121316,stroke-width:2.5px style mem fill:#aee5c2,stroke:#121316 style tmp fill:#f3c5a3,stroke:#121316 style start fill:#ffffff,stroke:#121316 style q1 fill:#fbfaf6,stroke:#121316 style q2 fill:#fbfaf6,stroke:#121316

Walk the tree. First question: is it the work — something a recipient is meant to receive? If yes, it's workspace, and you don't even need to name it, because workspace is the default. If no, second question: is it learned context the agent should keep across sessions but should never leave the box? If yes, it's memory. If it's neither — a half-finished intermediate, a cache, a thought you're glad to lose — it's tmp, and you should expect it gone by the next wake.

One name collision to defuse before it bites you. SQLite has a connection string :memory: that means "keep this database in RAM" — and the runtime uses it as the default store for ad-hoc, throwaway Instances. That :memory: is a SQLite storage location. It is not the memory volume. One is where the whole database lives; the other is one column-value inside a database. Same word, unrelated ideas — don't let the spelling fool you.

where the names END

Honesty section. The volume names promise less than they might seem to, and the gaps are worth stating plainly.

Stripping is on resume and egress — not continuous. tmp isn't being scrubbed in the background. It's cleared at the moment of resume, and private volumes are removed at the moment of ship. A session frozen in cold storage still contains its tmp and its memory, intact, in the file — the policy is two events, not a daemon. If you copied a frozen .sqlite out of cold storage directly, every volume would be there.

public_only fails open on garbage. The strip function is wrapped in a rescue: if the bytes handed to it aren't a valid SQLite file, it returns them unmodified rather than raising. That's a fail-open fallback, and we'd rather you know it than discover it. For valid VFS bytes — the only thing that ever reaches it in normal flow — it strips correctly; but it does not fail closed on malformed input.

Volumes are lifetime boundaries, not security boundaries. They are not quotas, not permissions, not encryption. All three volumes are the same bytes in the same file, in one trust domain, until egress. A component granted the vfs capability can read across volumes with a raw query — private here means "doesn't travel," not "sealed." For sealing, see sealed sections.

memory is reserved canon more than hot path. The runtime reserves the memory volume as the named, persisted, never-shipped home for agent long-term memory — that's the contract. But in production today, most agent memory lives as ordinary files in a context repo, and the in-repo writers of the memory and tmp volumes are the demos. Don't read this page as "agents are constantly writing the memory volume in prod." Read it as: the region exists, with its contract guaranteed, for when they do.

questions people actually ASK

Can I add a fourth volume?

No. The set ~w(workspace memory tmp) is closed by a function guard — when volume in @volumes — so a fourth name has no matching clause to land in. It isn't a config you raise; it's a fixed part of the model. Three lifetimes was the whole design.

Is the memory volume encrypted?

No. Private means "doesn't travel on share," not "sealed." memory and tmp sit in the same file as workspace, in one trust domain, and a component with the vfs grant can read them. If you need bytes that stay opaque even to code inside the box, that's sealed sections, a different mechanism.

Does freezing a session lose my tmp?

No — freeze loses nothing. Freeze is a plain file copy; all three volumes ride into cold storage intact. It's resume that clears tmp, with one DELETE, at wake time. A frozen file still holds its scratch; the byte dies on the way back up, not on the way down.

Can one component read another volume?

Yes, if it's been granted the vfs capability — the Dock's raw vfs-query sees the volume column, so a query can scope to or across any volume. This is the point worth internalizing: volumes are lifetime boundaries, not security boundaries. They decide what survives a wake and what travels on a share — not who's allowed to look.

So what does :memory: mean then?

It's a SQLite connection string meaning "keep this whole database in RAM," used as the default store for throwaway Instances. It has nothing to do with the memory volume — one is where a database lives, the other is a value in a key column. Identical spelling, unrelated concepts.

What persists off-box if my machine dies?

Litestream replicates the whole VFS file — all three volumes, since replication sits below the volume layer — to a file or S3-compatible target. The volume contracts are about lifetime and egress within a session; durable off-box backup is a separate concern, covered in sync.

keep GOING

Volumes are one slice of the disk's geography — its neighbors fill in the rest.