What a document is on disk
A Field document is a directory. Inside it are the saved box graph, the code in each box, and records of the document's history and operations. You can inspect these files with ordinary tools, and you can edit some of them by hand.
Here's what Field writes on a save, what it reads when you reopen the document, and which copy
takes precedence when a value appears in more than one file. For files your own code uses, such as
data.csv, see File management.
What one save writes
SavingContext.save runs in two phases. The main thread snapshots each box's persistent properties
into memory; a coroutine on Runloop.miscPool then does all the I/O, holding a per-document lock
keyed on the canonical path, so two saves of the same document serialize and a save of a different
one does not wait. Within the lock the order is fixed: sidecars first, then metadata.edn, then
document.md.
| File | Holds | Backups kept | Read by the loader |
|---|---|---|---|
metadata.edn |
every persistent property of every box, plus an inline copy of each sidecar | unlimited | yes — this is the file that is parsed |
<box>_<property>_.<ext> |
one sidecar-backed property, verbatim | 10 | yes, and it beats the inline copy |
document.md |
a Markdown rendering of the same boxes | none | no |
transcript.edn |
the flight recorder: what ran, and when | 10 | no |
history_metrics.edn |
per-version line counts for the history panel | 10 | no |
operations.yaml |
the operation transcript: moves, edits, executes | 10 | no |
versions.edn |
version names you typed, and full-restore transitions | none | no |
Only metadata.edn is always there. A document saved by an older Field has no document.md; one
never opened interactively has no operations.yaml; and versions.edn appears only once you name
a version or restore one.
The three backup policies are three calls to one function in the save body of saving.kt, with
different arguments: sidecars go through
src/jvmMain/kotlin/afield/document/persistence/saving.kt:462,
metadata.edn through
src/jvmMain/kotlin/afield/document/persistence/saving.kt:488
with no maxBackups at all, and document.md through
src/jvmMain/kotlin/afield/document/persistence/saving.kt:499.
maxBackups = null means unlimited — src/jvmMain/kotlin/afield/document/persistence/saving.kt:1821
only prunes when a bound was given — so backups/ in a document you have used for a while is mostly
one filename. Nothing anywhere prunes metadata_backup_*.edn.
A save that changes nothing writes nothing. File.safeSave reads the current contents first and
returns false on a byte-identical write
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:1829), and the
save path treats that false as "not altered" and skips history, metrics and the transcript
checkpoint entirely.
An example
Thirteen saves of a three-box document, into a temp directory, driven by the real save:
after three saves of a three-box document:
AGENTS.md
alpha_code_.kt
beta_code_.kt
beta_fragment_.glsl
document.md
history_metrics.edn
metadata.edn
backups/
alpha_code__backup_00000.kt
alpha_code__backup_00001.kt
history_metrics_backup_00000.edn
history_metrics_backup_00001.edn
metadata_backup_00000.edn
metadata_backup_00001.edn
resolved code for box alpha, loose file and inline copy diverged:
live load -> println("edited on disk")
history load -> println("three")
after 13 saves, backups/ by stem:
alpha_code_ 10
history_metrics 10
metadata 12
document 0
Regenerate it with
uv run --project . --group backdoor python tools/run_field_tests.py --id examples.document-on-disk.smoke
which runs
src/jvmMain/kotlin/afield/examples/DocumentOnDiskSmokeTest.kt:35.
Two things in that listing are not about backups. AGENTS.md is seeded into any
new document directory from templates/ by
src/jvmMain/kotlin/afield/document/persistence/saving.kt:1682, once,
without overwriting a file you have edited. And there is no operations.yaml or transcript.edn
here at all: both are written only from state accumulated in memory by an interactive session, and a
document saved by a program that never moved a box has nothing to checkpoint.
Which artifact wins on load
Opening a directory parses metadata.edn, and through it the sidecars it names
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:1014
is the failure when it is absent). The loader never reads document.md, which says so in its own
first paragraph
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:673), so
editing it cannot change what a document opens as. It is written for code and AI tools: a box graph
you can read without a Field. Field's own completion harness reads it that way, rebuilding each
document's box graph from the fences
(src/jvmMain/kotlin/afield/runtime/completion/CompletionCorpus.kt:105).
A metadata.json from a much older Field is still loadable, and is rewritten as EDN immediately —
src/jvmMain/kotlin/afield/document/persistence/saving.kt:1020 runs as part of the
open.
Within metadata.edn, a sidecar-backed property is stored twice: as a loose file, and as an inline
string under an #afield/file-inline tag carrying both path and inline
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:1630). One
line decides which copy a load uses:
is FileInline -> {
val file = File(directory, value.path)
if (!preferInlineFiles && file.exists()) sidecarOrNewerInline(file, value, directory) else value.inline
}
src/jvmMain/kotlin/afield/document/persistence/saving.kt:1546.
So if the loose sidecar file exists, its contents win, with one exception: when metadata.edn
was written more than two seconds after the sidecar and its inline copy differs from it, the inline
copy is loaded instead
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:1510).
That is a hand edit of the inline field made while the document was closed. Before loading it,
Field copies the sidecar into backups/ and logs a warning,save line naming the copy, so neither
edit is lost. A checkout writes both files at once, and the two seconds keep it on the sidecar. Edit
the sidecar after metadata.edn and the sidecar wins. Frames, names, topology and
everything else on the same box are always read from metadata.edn exactly as written. The choice
is per property, and only for the property names in
src/jvmMain/kotlin/afield/document/graph/Boxes.kt:498.
preferInlineFiles inverts it, and there is one caller who wants that: the history machinery, which
resolves an old version's properties with
src/jvmMain/kotlin/afield/document/history/history.kt:809.
Reading a backup, the loose file on disk is the current code, not the historical one, so the inline
copy is the only correct source. That is why the inline copy exists;
src/jvmMain/kotlin/afield/examples/HistoryFileInlineSmokeTest.kt:47
checks both cases side by side.
If you have edited sidecars and want metadata.edn's inline copies brought back into agreement
without opening the document,
src/jvmMain/kotlin/afield/document/persistence/saving.kt:1436
does exactly that and nothing else.
Editing the directory under a running Field
Field watches the document directory while it is open, and the three artifacts get three different
treatments. src/jvmMain/kotlin/afield/document/persistence/FileWatchManager.kt:291
is the branch.
A sidecar edit is picked up. An external write to <box>_<property>_.<ext> reloads that property
onto that box and resynchronizes it; if the box id in the filename is not in the session, the watcher
creates the box, gives it a free grid slot, loads the property and saves. Deleting a sidecar
externally only stops the watcher tracking it; the in-memory property is left alone
(src/jvmMain/kotlin/afield/document/persistence/FileWatchManager.kt:326). This is the
supported way to edit a box's code from outside Field.
A metadata.edn edit is reconciled. An external modification runs
src/jvmMain/kotlin/afield/document/persistence/FileWatchManager.kt:500,
which adds boxes present on disk and absent from the session, and tombstones boxes that were in the
last metadata.edn Field wrote or loaded and are absent from the new one
(src/jvmMain/kotlin/afield/document/persistence/FileWatchManager.kt:578).
A box created in the session since that write is left alone: it was never in the file, so the edit
cannot have removed it. A property of a box already open is applied when the new file's value
differs from that box's value in the last metadata.edn Field wrote or read
(src/jvmMain/kotlin/afield/document/persistence/FileWatchManager.kt:734),
so a value you changed in Field since the last save, and the file did not touch, is kept. Two things
the reconcile handles differently: edits to children and parents connect or disconnect boxes,
while nesting is handled by the add/remove passes. Deleting a property from the file does not remove
it from an open box; the next save writes it back.
A document.md edit does nothing, for the reason above: nothing reads it, and the watcher does
not decode it either.
Field recognises its own writes by content: a watch event is discarded only while the file still
hashes to what Field wrote
(src/jvmMain/kotlin/afield/document/persistence/FileWatchManager.kt:260),
so an edit made moments after a save is seen. The watcher registers the document directory itself and no
subdirectory
(src/jvmMain/kotlin/afield/document/persistence/FileWatchManager.kt:175), so nothing under
files/ is watched at all.
The autosave guard
An autosave — a save with no message — that carries fewer boxes than the metadata.edn already on
disk is refused rather than written, when the document has at least four boxes and at least a
quarter of them (minimum four) are missing from the snapshot:
src/jvmMain/kotlin/afield/document/persistence/saving.kt:636.
The notification says so —
src/jvmMain/kotlin/afield/document/persistence/saving.kt:444 — and an explicit save
with a message bypasses the check entirely, so you can still delete half a document and save it.
The three write-only files
operations.yaml is the operation transcript, and it accumulates in memory rather than on disk.
Every box move, property edit, execute, launch and stop appends an entry to an in-memory list;
nothing reaches the file until a save calls
src/jvmMain/kotlin/afield/document/persistence/saving.kt:511, and that call is inside
the if (altered) branch, so a save that changed no bytes does not checkpoint either. The
checkpoint rewrites the whole file from the whole in-memory list
(src/jvmMain/kotlin/afield/tools/automation/operation_transcript.kt:821),
which is why it grows monotonically within a session and why nothing prunes it: there is no
append path to bound. Edits are coalesced — repeated edits to the same property within 12 frames
collapse into one entry, and _editorState properties are skipped outright
(src/jvmMain/kotlin/afield/tools/automation/operation_transcript.kt:725).
It is written atomically, which matters because the save path and any other process sharing the
workspace can both be reading it. File.safeSave has an opt-in atomic mode that writes a temp
file in the same directory and renames it into place
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:1845),
taking a copy rather than a rename for the backup so the original never briefly disappears. All
five transcript writes pass atomic = true. Document saves do not: metadata.edn, the sidecars and
document.md go through the plain writeText branch.
transcript.edn is the flight recorder, and it is per Field process rather than cumulative. It is
loaded fresh on every launch and previous contents are never read back
(src/jvmMain/kotlin/afield/tools/automation/transcript.kt:402),
so the first write of a new process replaces it — and backs the old one up exactly once, because
createBackup is !state.previousProcessArchived. A document with ten
transcript_backup_*.edn files has been opened by ten Field processes that ran something.
history_metrics.edn is written on every altered save, and it is the one artifact here whose write
is bounded but not atomic:
src/jvmMain/kotlin/afield/document/history/HistoryMetrics.kt:421
passes maxBackups and not atomic.
What is in document.md, for the tool that reads it
document.md re-encodes metadata.edn in a form a text tool can walk. Its shape is fixed by
src/jvmMain/kotlin/afield/document/persistence/saving.kt:664:
a field-document EDN header, then one section per box in parent-first order
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:777),
each with an edn [field-box id="…" role="properties"] block carrying every property, followed by
one fenced block per text property tagged [field-property box="…" name="…" path="…"].
Each box's EDN block also carries a blockProperties manifest. It lists, per text property, the
name, the language, the sidecar path when there is one, and the lines, characters and
sha256 of the text
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:948). A tool can
therefore tell whether the fence it is reading still matches the sidecar beside it without opening
the sidecar. Fence lengths adapt: a property whose text contains a triple backtick is emitted inside
a longer fence rather than escaped
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:918), and a leading comment block on a
code property is lifted out as narrative prose above the fence
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:860).
Which properties a document saves
Box.ioPersistentProperties is a mutableSetOf, not a val of constants, and two things add to
it while Field runs. Each small language adds its own root markers when it installs — Nano's
isNanoRoot, nanoLaunchDelayMs and nanoMemoryInit in one call at
src/jvmMain/kotlin/afield/lang/nano/NanoInterface.kt:502,
and the other language roots the same way. Box.syncToProperty, the helper that wires an
output-panel control to a box property, marks that property persistable in each of its five type
branches
(src/jvmMain/kotlin/afield/ui/output/outputs.kt:2465).
A document's own header counts too, but only for that document. Loading one records its
:persistentProperties list against the document's folder
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:1329),
and a save writes the global set plus that document's names
(src/jvmMain/kotlin/afield/document/graph/Boxes.kt:809).
A property one document's header lists keeps being saved in that document, even before any code in
this session has declared it, and opening it does not change what any other document saves.
Inserting a template or pasting boxes brings their header's names into the receiving document.
That is why the list in a saved header is usually longer than the one declared in
src/jvmMain/kotlin/afield/document/graph/Boxes.kt:428. Most of the
extra names are language-root markers registered by the first mechanism above (the is…Root flags,
mainLanguage, Nano's configuration properties); the rest were declared in an earlier session and
have been carried in that document's header ever since. The names the first two mechanisms add are
still process-wide: they are declarations by code, not by a document.
What metadata.edn is mostly made of
Only properties whose names match one of the patterns in
src/jvmMain/kotlin/afield/document/graph/Boxes.kt:428 are written at
all; everything else on a box is dropped at save. One of those patterns is a regex, .*_editorState,
and it is why a metadata.edn is larger than its boxes suggest: every editor keeps cursor and fold
state per box per property, and that state is saved.
That filter runs before the sidecar branch
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:153),
so a property listed in ioSeparateProperties alone is written nowhere: the sidecar branch is never
reached, and nothing lands inline in metadata.edn either. The two lists have to agree: every key in
src/jvmMain/kotlin/afield/document/graph/Boxes.kt:498 is also matched by
a persistent pattern.
src/jvmMain/kotlin/afield/examples/SidecarPropertyRegistrationSmokeTest.kt:50
(examples.saving.sidecar-property-registration) checks that across the whole registry, then drives a save
through toTransfer and asserts that a property in neither list leaves no sidecar and no EDN key.
The root box is excluded from the sidecar branch by id, so the root's metadata property stays
inline in metadata.edn — that is the property
src/jvmMain/kotlin/afield/document/persistence/saving.kt:1056
edits — while a non-root box's metadata becomes a <box>_metadata_.yaml sidecar. Sidecar filenames
are id + "_" + name + "_." + extension and nothing else
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:1651), which is why
they read as UUIDs: the id is the box's uid, not its name, and renaming a box does not rename its
sidecars.
Backup filenames
<stem>_backup_00000.<ext>, zero-padded to five digits, allocated as one past the highest index
present rather than by rotation
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:1906),
so index order is save order and the numbers do not shift under pruning. metadata.edn becomes
metadata_backup_00000.edn; alpha_code_.kt becomes alpha_code__backup_00000.kt, with the double
underscore, because the stem already ends in one. A backup is skipped when the previous contents are
already the newest backup, so a save that reverts a file and saves again does not duplicate it.
Pruning drops the oldest. In the thirteen-save run above the surviving
alpha_code__backup_*.kt are indices 00002 to 00011 — the first two were deleted, and no index was
reused — against metadata_backup_00000.edn through metadata_backup_00011.edn still all present
(src/jvmMain/kotlin/afield/document/persistence/saving.kt:1920).
The history panel reads exactly these files:
the backups/metadata_backup_*.edn set sorted lexically — so index order is version order — with
the live metadata.edn appended as the newest version
(src/jvmMain/kotlin/afield/document/history/history.kt:16).
Document History covers using it. On disk, history costs one unpruned copy
of metadata.edn per changed save, and that is the only artifact in the directory with no bound on
how much it can accumulate.