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
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:
| volume | what it's for | survives resume? | ships on share? | who writes it by default |
|---|---|---|---|---|
| workspace | the workbook's files — the working tree | yes — persists | yes — it's the work | everything — it's the default |
| memory | agent long-term memory | yes — persists across freeze | no — stays home | reserved for agents (see honesty) |
| tmp | scratch — half-thoughts, intermediates | no — cleared on the next wake | no — stays home | code 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.