03 Format
The .nestbound file format, spec v2
The complete specification for the .nestbound file, published so that your family’s binder does not depend on us being here to open it.
About this copy. This page is copied from the app repository at build time. It is the same document that ships with the app. Copied verbatim from app/FORMAT.md at commit 8dde8b0, 23 August 2026.
Source checksum sha256:556c9026471d8b09cb56f9edfb197db905b5fcc919e00f6abf36e820f4fec3ac.
This document is the complete, public specification of the Nestbound document format. The promise: any competent developer can recover a family’s data with a zip tool and a JSON reader. If this spec and the app ever disagree, that is a bug in the app.
v2 (identifiers, ARCHITECTURE.md §12). Items gained a
protectedFieldscontainer for stored identifiers (account numbers, IBANs, policy/member numbers, crypto receiving addresses), attachments gainedexcludeFromBinder, andauthorNotesgainedidentifierConsentAt. All three are additive; a v1 file is upgraded by the read-time migration. Field names are permanent.
1. Container
A .nestbound file is a standard zip archive (PKZIP; entries deflate- or
store-compressed) containing:
manifest.json package metadata (always plaintext)
dossier.json the entire structured content
media/ attached photos/scans, one file per attachment
media/<uuid>.<ext> blob referenced by id from dossier.json
- All JSON is UTF-8, no BOM.
<uuid>in media filenames is the attachment’sid, lowercased (e.g.media/11111111-1111-4111-8111-111111111111.jpg).- Readers must ignore files inside
media/that do not match the<uuid>.<ext>naming rule, and must not treat them as fatal. - Nestbound writes a
media/directory entry; readers must not require it. - A passphrase-protected package holds
dossier.encandmedia/<uuid>.encinstead of the two plaintext content entries above;manifest.jsonis unchanged and still plaintext. See §7.
2. Dates and other scalar conventions
-
Dates are ISO 8601 UTC strings with milliseconds:
"2026-07-01T10:30:00.000Z". Readers should also accept whole-second ISO 8601 ("2026-07-01T10:30:00Z"). -
UUIDs are RFC 4122 strings. Nestbound writes them uppercase in JSON; readers must compare case-insensitively. Media filenames use lowercase.
-
Required vs optional is stated per field, in the Req. column of every table below: yes means a reader may refuse a document without it, and no means the field may be omitted or explicitly
nulland the reader supplies the documented default. Optional fields are omitted when absent, and readers must accept both an omitted key and an explicitnull.This column is new. The tables used to mark nothing at all, while the decoder required
completeness,authorNotes,fields,attachments,flagsand both flag booleans — so adossier.jsonwritten by hand from this document was refused four times in a row with a rawkeyNotFound, on a format whose whole pitch is that you can write it by hand. Those seven are now genuinely optional in the reader as well as on paper. -
Nestbound writes JSON pretty-printed with lexicographically sorted keys, and its decode→encode round trip is byte-identical. Other writers are not required to match this formatting; content, not formatting, is normative.
3. manifest.json
| Field | Type | Req. | Meaning |
|---|---|---|---|
schemaVersion |
integer | yes | Format version of dossier.json. This spec is version 2. |
appVersion |
string | yes | Marketing version of the app that last wrote the file, e.g. "1.0.0". Informational. |
createdAt |
date | yes | When the dossier was first created. |
modifiedAt |
date | yes | When the package was last saved. |
encryption |
object | no | Absent/null means plaintext. Present means the content entries are encrypted; the manifest itself never is. See §7. |
Example:
{
"appVersion" : "1.0.0",
"createdAt" : "2026-06-01T09:00:00.000Z",
"modifiedAt" : "2026-07-01T10:30:00.000Z",
"schemaVersion" : 2
}
Versioning rules
- Readers must open all versions ≤ their own (older files are upgraded by pure migration functions at read time).
- Writers write the current version only.
- A reader seeing
schemaVersiongreater than it supports must refuse with a “made with a newer Nestbound” error — never guess.
4. dossier.json
Top level:
| Field | Type | Req. | Meaning |
|---|---|---|---|
subject |
object | yes | The person this dossier is about. |
sections |
array | yes | The dossier’s sections, in display order. |
completeness |
object | no | Map of section id → completion fraction (number, 0.0–1.0). May be empty or absent (absent = empty). Maintained by the app; informational. |
authorNotes |
object | no | Free-form container for app metadata; see below. May be empty or absent. |
4.1 subject
The JSON key is subject and is permanent API. The English around it is not:
Nestbound never refers to a person that way (Scripts/tone-rule.json), and this
document both ships inside the app and is published to strangers.
| Field | Type | Req. | Meaning |
|---|---|---|---|
name |
string | yes | The name of the person this dossier is about. Doubles as the “family name” on the binder cover. |
relationship |
string | yes | Relationship of the author to that person: "self", "parent", "spouse", or "other". |
4.2 sections[]
| Field | Type | Req. | Meaning |
|---|---|---|---|
id |
string | yes | Section identifier. The 12 canonical ids are listed in §5. Ids are stable API; display labels are localized by apps and never stored. |
items |
array | no | The section’s items. May be empty or absent (absent = empty). |
Readers must preserve sections whose id they do not recognize.
Unknown keys inside dossier.json are preserved (since 13 August 2026).
Anywhere in this file, a key Nestbound does not recognize is read, carried and
written back untouched, in sorted order so the document stays byte-identical
across a round trip. Every object in dossier.json — the top level,
subject, a section, an item, its flags, an attachment, and authorNotes —
so put your keys wherever they belong rather than where a container happens to
be free-form.
This used to be false, and the consequence was severe for an open format: every
model is a fixed-key struct and the store re-encodes from the structs, so
opening somebody’s file, changing one phone number and saving deleted whatever
their tool had added. authorNotes was the worst of it, because ARCHITECTURE.md
describes it as a free-form container for app metadata and it was not one.
It was then half-true for two weeks, which is worse than either: this
paragraph said “anywhere in this file… or at the top level” while only items and
authorNotes implemented it, so the one place the sentence above pointed a
third-party tool at was a place its data would silently vanish from. Every
container now implements it, and a test round-trips a foreign key at each level
and asserts every stored property still reaches the file.
Two limits, stated rather than implied. Preserved means PRESERVED: Nestbound
never interprets an unknown key and never lets one change its behavior. And
manifest.json is NOT covered — it is the identity block, deliberately minimal,
and the one part that stays plaintext in a protected package; keep additions to
dossier.json.
4.3 items[]
| Field | Type | Req. | Meaning |
|---|---|---|---|
id |
string (UUID) | yes | Stable item identity. |
template |
string | no | Identifier of the item template this item was created from — the interview content’s item key, e.g. "executor", "will", "identity". Nestbound uses it to find the same item again across sessions (interview prefill and updates, checklist assembly, editor layout). Values are unique within a section among interview-created items. Absent means the item was created manually (or by a third-party tool); that is a perfectly valid item. Tools must preserve this field untouched and must not invent values for it. |
fields |
object | no | Typed values keyed by field name; may be empty or absent (absent = empty). values may be any JSON type. The field vocabulary is defined by item templates (interview content), not by this spec. Unknown fields must be preserved. Never holds identifiers (see protectedFields) or authenticators. |
protectedFields |
object | no | v2. Stored identifiers keyed by field name: account numbers, IBANs, policy/member numbers, crypto receiving addresses (ARCHITECTURE.md §12). A sibling of fields, not a subset, so every consumer that iterates fields blind (the binder body, the spreadsheet exporters) sees nothing here unless it opts in. Nestbound prints only the last four of each value in the binder and omits these from CSV/XLSX/plain-text exports; only the lossless JSON export carries them. ONE EXCEPTION, added 26 July 2026: receivingAddress prints in full. A crypto receiving address is public by design — it is what you hand somebody so they can pay you — so last-four redacts nothing an attacker could not derive and destroys the only use the field has, which is looking the wallet up on a block explorer. Spending needs the private key, which is an authenticator and has no container anywhere in this format. It stays in protectedFields, so the spreadsheet exports still leave it out. Never holds authenticators — no PIN, password, CVV, seed phrase, private key, or safe combination. May be empty or absent (absent = empty). Unknown keys must be preserved. |
attachments |
array | no | Attachment references; may be empty or absent (absent = empty). |
updatedAt |
date | yes | When the item was last edited or reviewed. |
flags |
object | no | Item status flags; each flag defaults to false when the object or the key is absent. |
template and the keys inside fields are permanent, in the same sense
JSON field names are. They are the interview content’s itemKey and
fieldKey, and they are the only thing tying an answer already on disk to the
question that asked for it — nothing in this format records which version of the
interview content wrote a value. Rename one and the old answer survives in the
file under a name nothing asks about any more, while the question it belonged to
comes back blank. Add keys freely; do not rename or repurpose them.
An item carrying a non-empty protectedFields answers to a shorter review
clock (~6 months rather than 12): custodians reassign identifiers silently, so
a number that has quietly gone wrong is worse than none.
4.4 attachments[]
| Field | Type | Req. | Meaning |
|---|---|---|---|
id |
string (UUID) | yes | Identity of the media blob; the blob lives at media/<id lowercased>.<fileExtension>. |
fileExtension |
string | yes | Lowercase extension without dot, e.g. "jpg", "png", "pdf". |
caption |
string | no | User caption; the binder’s attachment appendix prints it. |
excludeFromBinder |
boolean | no | v2. When true, the binder’s full-size appendix omits this scan; the blob and its media/ bytes stay in the file, and the JSON export still carries it. Absent = false. Lets a user keep a scan of a sensitive document in the file but off the printed page. |
4.5 flags
| Field | Type | Req. | Meaning |
|---|---|---|---|
needsReview |
boolean | no | Absent = false. The user marked this item as needing another look. |
stale |
boolean | no | Absent = false. Advisory, and not authoritative — do not rely on it. Staleness is COMPUTED, everywhere, from updatedAt against the item’s review interval (12 months by default; ~6 months when protectedFields is non-empty — ProtectedFields.reviewInterval), so the app, the binder and the exports agree even about a file they have never opened before. Nestbound never sets this flag true and never reads it; it is written only by fixtures and kept because files in the wild carry it. A reader should ignore it and compute from updatedAt; a writer should preserve whatever it finds. Corrected 12 August 2026 — this row previously described it as live, which it has not been since the shared staleness rule landed. |
4.6 authorNotes
Every field here is optional; authorNotes itself may be absent.
| Field | Type | Req. | Meaning |
|---|---|---|---|
interviewProgress |
any JSON | no | Interview-engine resume state. Owned by the app’s interview engine; other tools must preserve it untouched. |
fileLocationNote |
string | no | One-line, user-written “where this file lives” note; the binder’s final page prints it for future readers. |
identifierConsentAt |
date | no | v2. When the user first accepted the identifier-storage explainer (ARCHITECTURE.md §12). Present once an identifier has been stored knowingly; the app never re-asks. Absent = not yet acknowledged. |
notApplicableSections |
array of strings | no | Section ids the author has marked “does not apply to us” — a considered blank, distinct from a not-yet-filled one. Additive: absent (rather than empty) in any file that has never used it, and kept sorted on write so two dossiers with the same set serialize identically. A reader that does not understand it must preserve it. |
visitPlan |
object | no | What the next sit-down-together session is meant to cover. Sub-fields: sectionIDs (array of canonical section ids) and plannedAt (date). Holds section ids and dates only — never an answer, and never a word anybody typed. Additive and absent when unused. |
ritual |
object | no | The annual refresh. Additive; absent in any file that has never used it, and removed again (rather than written empty) when everything in it is cleared. Sub-fields: reminder (object, optional — month 1–12, day 1–28, optedInAt date), lastBinderAt (date, optional — when binder pages were last printed or saved), lastRefreshAt (date, optional — when the yearly read-back was last completed), placementShownAt (date, optional — when the “where the binder should live” screen was shown, once). day is capped at 28 because 29–31 do not exist in every month. The reminder is an intent, not a schedule: the notification itself belongs to the machine that scheduled it, so a reader may offer to schedule its own and must never assume one exists. |
5. Canonical section ids
Ids are permanent API. Labels here are descriptions, not stored strings.
personal— Personal and family information (names, dates, parents, work, military service)contacts— Key people (executor, attorney, accountant, doctor, clergy, neighbors)documents— Documents and where they live (will, trust, deeds, certificates, safe-deposit box)financial— Accounts inventory (institution, type, ownership; account numbers and IBANs, when stored, live inprotectedFields— never a password or PIN)insurance— Policies (carrier, type, policy location, agent; policy/member numbers inprotectedFields)property— Property, vehicles, storage, safes (secrets by reference, never the secret itself)digital— Digital life (services, password-manager emergency kit location, legacy contacts)health— Health basics, medications, providers, directives location, organ donationpets— Pets and their carewishes— Funeral and memorial preferencesletters— Letters and messages to specific peopleexecutor— First-two-weeks checklist for the survivor
New documents contain all 12 sections, empty, in this order.
6. Reading and writing rules
- Atomic saves. Writers must never mutate a
.nestboundfile in place. Nestbound serializes to a temp location, fsyncs, then atomically replaces the destination; a crash mid-save leaves the previous file intact. Third-party writers should do the same. - Garbage collection. On save, media blobs that no attachment references are dropped from the package.
- Corrupt input. A reader must fail with a clear error — not crash — on:
truncated/non-zip data, missing
manifest.json, missingdossier.json, malformed JSON, or an unsupportedschemaVersion.
7. Encryption (optional; shipped)
A .nestbound file is plaintext unless the user turns on passphrase protection.
Protection is opt-in, per document, and changes no version number: the
encrypted variant is a schemaVersion 2 package like any other, and the
encryption block was reserved in v1 precisely so it would never need a bump.
manifest.json is never encrypted. Identifying a file, reading when it was
written, and learning exactly how to derive its key must not require the
passphrase — otherwise a protected file is indistinguishable from a corrupt one,
which is a bad way to greet somebody who has just lost a parent.
7.1 manifest.encryption
| Field | Type | Req. | Meaning |
|---|---|---|---|
algorithm |
string | yes | "AES-256-GCM". Readers must refuse anything else rather than guess. |
keyDerivation |
string | yes | "PBKDF2-HMAC-SHA256". |
iterations |
integer | yes | PBKDF2 iteration count. Nestbound writes 600,000; readers honor whatever the file says, within sanity limits (Nestbound refuses < 1 or > 10,000,000 — a plaintext manifest is editable by anyone, and an hour-long derivation is a denial of service). |
saltBase64 |
string | yes | The random salt, base64. Nestbound writes 16 bytes; readers accept any non-empty length. |
passphraseHint |
string | no | The user’s own words. Never derived from the passphrase, never checked against it. |
formatVersion |
integer | no | Version of the encrypted layout described in §7.2–§7.4, independent of schemaVersion, which versions the JSON inside. Absent means 1, the version specified here. A reader seeing a higher number must refuse. |
{
"appVersion" : "1.0.0",
"createdAt" : "2026-06-01T09:00:00.000Z",
"encryption" : {
"algorithm" : "AES-256-GCM",
"formatVersion" : 1,
"iterations" : 600000,
"keyDerivation" : "PBKDF2-HMAC-SHA256",
"passphraseHint" : "Where we met, and when",
"saltBase64" : "yBTsPBiUmZ4Utl0PmQe0Kg=="
},
"modifiedAt" : "2026-07-01T10:30:00.000Z",
"schemaVersion" : 2
}
7.2 Package layout
| Plaintext package | Protected package |
|---|---|
manifest.json |
manifest.json — identical, still plaintext |
dossier.json |
dossier.enc |
media/<uuid>.<ext> |
media/<uuid>.enc |
And nothing else: a protected package contains no dossier.json, no plaintext
media blob, and no sidecar of any kind. Everything a dossier holds — including
each item’s protectedFields (§4.3, ARCHITECTURE.md §12) — is inside
dossier.enc, because that entry is the whole of dossier.json.
The blob’s real file extension is deliberately not in the encrypted name; a
protected package does not advertise which attachments are PDFs and which are
photographs. It is recovered from the attachment record inside dossier.enc
(fileExtension), which is where it is authoritative anyway.
7.3 Byte layout of an encrypted entry
Every .enc entry, whatever it holds, is:
┌────────────────┬──────────────────────────┬────────────────┐
│ nonce 12 bytes │ ciphertext = plaintext n │ tag 16 bytes │
└────────────────┴──────────────────────────┴────────────────┘
offset 0 offset 12 offset 12 + n
- Total entry size is exactly
n + 28. AES-GCM is a stream cipher, so the ciphertext is the same length as the plaintext. - The nonce is 12 random bytes, fresh for every entry and every save. Two saves of an unchanged document therefore produce different bytes; this is correct, not a bug. Reusing a nonce under one key destroys AES-GCM.
- The tag is the standard 128-bit GCM authentication tag, appended.
- This is byte-for-byte what CryptoKit calls a sealed box’s
combinedrepresentation, and what OpenSSL, Python’scryptography, and Go’scrypto/cipherproduce by default. That was the point of choosing it. - No additional authenticated data. An entry can be opened knowing only the key, which keeps hand recovery (§8) a twenty-line script. The trade is in §7.5.
7.4 Key derivation
key = PBKDF2-HMAC-SHA256(
password = UTF-8 bytes of the passphrase, Unicode-normalized to NFC,
salt = base64-decode(manifest.encryption.saltBase64),
iterations = manifest.encryption.iterations,
dkLen = 32 bytes)
- The NFC normalization is part of the algorithm, not an implementation detail. “Köln” typed on a German keyboard and “Köln” pasted from a document can be different byte sequences (composed vs. decomposed) for the same word; without normalization the same passphrase would sometimes fail to open the file. Any other implementation must normalize the same way.
dkLenequals the hash length, so there is exactly one PBKDF2 output block.- The same key encrypts every entry in the package.
- Changing the passphrase generates a new salt, so keys derived from the old passphrase (including any cached copy) stop working.
7.5 What this protects, and what it does not
Protected:
- The entire content of the dossier and every attached scan, against anyone holding the file without the passphrase — a stolen laptop, a backup drive, a cloud folder the file was dropped into.
Not protected, on purpose or by physics:
- The manifest. Creation and modification dates, app version, and the passphrase hint are readable by anyone. Do not write the passphrase in the hint.
- Shape. The number of encrypted entries and their sizes are visible, so an observer can tell roughly how many attachments a family has and how big they are.
- Rearrangement. Because no associated data binds an entry to its name or to the rest of the package, somebody holding the file cannot read or forge content, but can swap whole encrypted entries around, or splice in an entry from an older copy of the same file encrypted under the same key. Defending against that would mean binding each entry to its path, which would in turn make §8 recovery by hand harder for every honest survivor. This format chooses the survivor.
- Anything printed. A binder PDF is paper; a CSV or XLSX export is plaintext
on disk. Encryption covers the
.nestboundfile, nothing that leaves it. - A forgotten passphrase. There is no escrow, no master key, no recovery code, no vendor override — including for whoever wrote this app. Losing the passphrase loses the contents, permanently and by design.
8. Recovering data by hand
8.1 A plaintext file (the default)
unzip family.nestbound -d family/
cat family/dossier.json # every fact, readable JSON
open family/media/ # every photo/scan
That is the entire recovery procedure, and keeping it that simple is the point of this format.
Matching a scan to the entry it belongs to. Each attachment record in
dossier.json carries an id and a fileExtension; the file in media/ is
that id lowercased, plus the extension (§2). UUID prints uppercase in most
languages, so a literal media/<id>.<ext> lookup fails on a case-sensitive
filesystem and inside a zip reader — lowercase it:
# every scan, renamed to the entry it was attached to
python3 - <<'PY'
import json, pathlib, shutil
d = json.load(open("family/dossier.json"))
for section in d["sections"]:
for item in section["items"]:
for a in item.get("attachments", []):
src = pathlib.Path("family/media") / f"{a['id'].lower()}.{a['fileExtension']}"
if src.exists():
shutil.copy(src, f"{a.get('caption') or a['id']}.{a['fileExtension']}")
PY
8.2 A passphrase-protected file
That simplicity is gone, and that is the trade. unzip still works, and
manifest.json is still readable — enough for a future tool, or a technically
minded survivor, to identify what the file is, see when it was last saved, read
the hint, and know exactly which algorithm and parameters to apply. But
dossier.enc and media/*.enc are meaningless bytes without the passphrase,
and no amount of skill substitutes for it: the confidentiality that protects the
file from a thief protects it from its owner’s family too.
With the passphrase, no Nestbound code is required — §7 is enough to write the recovery in any language with a standard crypto library:
import base64, hashlib, json, unicodedata, zipfile
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
zf = zipfile.ZipFile("family.nestbound")
enc = json.loads(zf.read("manifest.json"))["encryption"]
key = hashlib.pbkdf2_hmac(
"sha256",
unicodedata.normalize("NFC", input("passphrase: ")).encode("utf-8"),
base64.b64decode(enc["saltBase64"]),
enc["iterations"], 32)
def open_entry(name): # nonce ‖ ciphertext ‖ tag
blob = zf.read(name)
return AESGCM(key).decrypt(blob[:12], blob[12:], None)
print(open_entry("dossier.enc").decode("utf-8"))
Attachment bytes come out of media/<uuid>.enc the same way, where <uuid> is
the attachment’s id lowercased (§2) — UUID prints uppercase in most
languages, and a literal lookup fails:
for section in json.loads(open_entry("dossier.enc"))["sections"]:
for item in section["items"]:
for a in item.get("attachments", []):
open(f"{a['id']}.{a['fileExtension']}", "wb").write(
open_entry(f"media/{a['id'].lower()}.enc"))
Both of these have been run. The script above recovered a complete
36-item dossier and its three scans from a sealed file on 26 July 2026, using
nothing but Python and PyPI cryptography — no Nestbound code. That is the
whole point of publishing it, and it is checked rather than asserted:
theRecoveryScriptPublishedInFormatMDActuallyWorks reproduces the same steps
in the test suite.
Appendix A. Export formats
Exports are derived files, not the format. Only the JSON export re-imports
losslessly; the others are one-way convenience copies. Nothing in this appendix
is part of the .nestbound contract, but the column machine names below are
stable API in the same way JSON field names are: a re-import matches on them,
and the CSV bundle records them in its _columns.csv sidecar.
Identifiers (v2). Values in an item’s protectedFields (ARCHITECTURE.md
§12) are omitted from the CSV, XLSX, and plain-text exports — those three
are content-indexed by the OS (Spotlight), which is the propagation risk the
policy measures against. They are not columns and never appear in the
_columns.csv sidecar. Only the JSON export carries them, whole, under each
item’s protectedFields object.
A.1 JSON export
A folder, not a package:
<Family> Nestbound Export/
manifest.json the package's manifest, including schemaVersion, but with
the `encryption` block removed — a JSON export is plaintext
by construction, so its manifest must match the bytes beside it
dossier.json byte-identical to the package's (same deterministic encoder)
media/<uuid>.<ext> every referenced blob, orphans already collected
README.txt plain-language note describing the three files above
Import accepts either the folder or the dossier.json inside it. A file whose
schemaVersion is older runs the same migration chain the package reader uses;
a newer one is refused rather than guessed at.
A.2 CSV bundle
One .csv per non-empty section, zipped as <Family> Nestbound Export.zip.
Files are named after the section’s export label (“Key people.csv”).
Encoding is deliberate and non-negotiable for Excel compatibility (ARCHITECTURE.md footgun 2):
- UTF-8 with BOM (
EF BB BF) - CRLF line endings
"quoting only where needed; embedded quotes doubled
One header row of display labels (“Full legal name”), then data from row 2. Nothing machine-readable is mixed into a section file: it opens in Excel or Numbers looking like a spreadsheet a person would write.
The machine names live in a sidecar, _columns.csv. It is one extra zip entry
alongside the section files — the bundle holds one file per non-empty
section, so a dossier with empty sections has fewer than twelve of those — and
it is not itself a section:
File,Section,Columns
Key people.csv,contacts,name,role,phone,email,note,attachments,updatedAt,…
First two weeks.csv,executor,task,order,firstCall,…
Column 1 is the section file’s name, column 2 its section id, and columns 3+ are the ordered machine names for that file. Rows are ragged by design.
An importer resolves columns in three steps, in this order:
- Sidecar — the user picked the bundle (the
.zip, or the folder it was unzipped into), so_columns.csvis readable. It is applied only after a reconciliation check: the section file’s header row must still read exactly as this export wrote it, cell for cell. The sidecar is a separate file, and machine names are applied by position, so a column deleted, renamed or reordered in a spreadsheet would otherwise silently shift every mapping. When the check fails the sidecar is discarded and resolution falls to step 3. - Legacy
#row — files exported before this change carried the machine names in a second header row whose first cell began with#. Nothing writes that any more, but it is still recognized. A row qualifies only if it is exactly as wide as the header, every cell is lowercase-initial camelCase, and the names are drawn from the export vocabulary (or the row still ends with the six metadata columns). A leading#alone is not enough: real data such as#12 Elm Streetmust never be mistaken for column names and deleted. - Neither — header text is matched against known field labels, and what
is left falls to one of two defaults:
- If the row ends with all six metadata columns in canonical order (§A.3), the file is almost certainly one of these exports with its sidecar left behind, so those six default to skipped: they hold values the app regenerates, not anything a person typed.
- Otherwise — including a stray
IDorTemplateamong a foreign sheet’s own columns — the column defaults to notes and its content is kept. One or two familiar names prove nothing; the full block in order is a signature.
The governing rule is no column is dropped without the user seeing it. Every column in the file is listed in the mapping sheet with its destination shown and changeable, so a default is a suggestion, not a decision: a skipped column is visible and one click from being kept, and no column may be discarded without appearing there.
That rule is about the file’s columns, and the mapping sheet says so in as
many words. What a CSV never contained — media and protectedFields
identifiers — was left behind at export time, before any mapping sheet
existed, and no choice made there can bring it back. The sheet states that
plainly and names the JSON export as the one that round trips; claiming
otherwise on a screen whose promise is not leaving things out was a real defect,
found on 12 August 2026.
Item ids and template are a different case, and this paragraph used to get
it wrong — it listed them beside media as things a CSV never held, while A.3
below defines both as columns every section file writes, and the real export
does write them. They are exported and then DISCARDED ON THE WAY BACK IN
(CSVImporter maps both to skip), so a re-import of your own export creates
new items rather than updating the originals: a spreadsheet is still one-way,
but for a different reason than the sentence claimed. The app’s own copy has
always said this correctly — “item ids and template links are regenerated on the
way back in”.
A single section file lifted out of the bundle lands in case 3, and that is
expected rather than a defect: a sandboxed app is granted access to exactly
what the user selected, so a lone Key people.csv genuinely cannot read the
_columns.csv sitting beside it. One consequence worth knowing: on that path
the six metadata columns are not recognized as metadata either, so they are
kept as notes rather than dropped.
A .nestbound package is also a zip and will pass a file picker’s type
filter; an importer should detect manifest.json + dossier.json and refuse
it rather than parse the package as a spreadsheet.
A.3 Column contract
Per section: curated field columns, then any extra field keys the document carries (sorted, never dropped), then these metadata columns in this order:
| Machine name | Meaning |
|---|---|
attachments |
Captions (or filenames) of the item’s attachments, newline-separated |
updatedAt |
yyyy-MM-dd, in the exporting machine’s time zone |
needsReview |
Yes/No |
stale |
Yes/No |
template |
The item’s template (§4.3), empty for manual items |
id |
The item’s UUID |
Metadata columns are regenerated on import, never written from cells. When the file identifies itself (sidecar or legacy row) they are recognized outright. Without that, all six appearing in this exact order at the end of a row is taken as the signature of one of these exports and they default to skipped; any smaller overlap defaults to notes and is kept. Either way the decision is shown in the mapping sheet before anything is imported (§A.2).
The sidecar (§A.2) records which machine name each column carries and in what order, and it is the intended source of that mapping — but it is not trusted blindly: it describes a separate file that a spreadsheet may since have edited, so it is applied only when the header row still matches. The display labels are localizable copy and may change between versions; the machine names may not.
Choice-typed fields (role, maritalStatus, exists, selfCustody, …) export
their reader label and import back to their machine token, so executor
leaves as “Executor / steady hand” and returns as executor.
Typed values. A spreadsheet cell has no type, so two columns are converted
back on import: order to a JSON number and done to a JSON boolean. These are
the only non-string values anything writes into fields{}, and both are read by
the executor checklist through accessors that return nothing for a string — so
without the conversion a round trip lost the checklist’s order and unticked
every completed task. Conversion is conservative and never invents a value: a
cell that does not parse cleanly stays a string. 1,5 and 1 000 are left
alone (the decimal separator is not ours to guess), non-finite forms such as
nan are refused outright (they would make the document unencodable), and a
done column reading “Pending” keeps that word. A bool accepts the exported
Yes/No labels plus true/false/1/0.
Machine names added after v1.0 are appended to their section’s column order, never inserted, so an older importer reading a newer export still finds the columns it knows in the positions it expects.
financialgainedexists,platforms,selfCustody,recoveryLocationandlist(self-custody and paper assets), thensources,formerEmployersandinterests(money coming in, former employers, a business or a rented-out property).personalgainedparents,occupation,servedandpapers.documentsgained nothing: a living trust is an item withtemplate"trust", and it writes theexistsandlocationcolumns that were already there.digitalgainedphoneNumberNote,healthgaineddecisionanddonationRecord, andexecutorgainednotifyOthers.
Two of those are typed. papers is a locationRef column, tinted like the
others, and holds a pointer — where discharge paperwork is kept — never the
document. served and decision are choice-typed and follow the reader-label
rule below. As with every locationRef, recoveryLocation holds where the
recovery instructions are kept and never a recovery phrase, key or password.
A.4 XLSX
One workbook, one sheet per non-empty section, sheet names within Excel’s
31-character limit and free of [ ] : * ? / \. Each section sheet has a
single bold header row with data from row 2, panes frozen below row 1, an
autofilter over the whole range, content-derived column widths, and tinted
locationRef columns. Written with libxlsxwriter.
The machine names live on their own worksheet named _columns, added last and
marked hidden (state="hidden" in xl/workbook.xml). Its first row is
Sheet, Section, Columns and each following row names a section sheet, its
section id, and that sheet’s ordered machine names — the same shape as the CSV
sidecar. Excel opens on the first sheet, which is always a real section.
This mirrors §A.2 rather than diverging from it: in both formats the reader
sees clean data and the machine names sit beside it. There is no XLSX importer
today, so the _columns sheet is fidelity for a future one, not a live
contract.
Cell length. Excel refuses to open a workbook containing a cell longer than
32,767 characters. Values over that limit are truncated with a visible marker
naming the .nestbound file as the place the full text lives. CSV and plain
text are not truncated: those formats have no such limit, the file stays
lossless, and truncating them would destroy data to solve a problem they do not
have. (Excel truncates over-long cells when opening a CSV; the file on disk
still holds everything, and other readers see it all.)
A.5 Plain text
One .txt in the binder’s reading order (the executor checklist first, then
the canonical section order), with stale markers and the fileLocationNote.
Intended as the maximum-longevity copy: no tooling required to read it.
Reading it on Windows, or without Nestbound
There is no Windows version, and we would rather say so than dangle a date. Your family is not stuck: the exports open on any computer, and because the format above is published, a Windows laptop can still read what you made. If enough families ask, we would look at a small read-and-print viewer, one HTML file with nothing to install. That is something we are considering, not something we have promised.