Developers › File formats Beta
The .textshelf shelf bundle, v1
Status: v1: stable to read; fields may be added; a higher schemaVersion means stop.
This is the format of a TextShelf shelf on disk, as the code reads and writes it
on 2026-10-12. Where the code and an earlier draft disagree, this follows the
code. It replaces spikes/s2-shelf-bundle/BUNDLE_FORMAT.md ("v1 draft").
What reads and writes bundles today:
- The TextShelf Mac app, through ShelfCore's
ShelfDocument. It reads and writes every file below exceptagent-brief.json, which it keeps unread. - The app's Quick Look extension and its App Intents catalog. They only read.
The web app does not read or write bundles. Other programs may, for example to let an agent read and mark a shelf. What they must do is in §3 and with each file.
Every example in this document is the output of the code's own encoder for that file, byte for byte, and each one decodes with the code's own reader. The names, ids and text in them are made up.
1. What a bundle is
A bundle is a directory whose name ends in .textshelf. macOS shows it as one
document: type com.textshelf.shelf, conforming to com.apple.package and
public.composite-content.
The JSON files are UTF-8. A reader must not depend on key order, spacing or line breaks.
2. Layout
<name>.textshelf/
├── shelf.json The shelf. Required.
├── settings.json The rubric.
├── web-link.json Only on a shelf built from a web review packet.
├── agent-brief.json What the shelf is for, for an agent.
├── assistant-policy.json What readers' own AI assistants may do.
├── submissions/
│ └── <document id>/ One folder per document.
│ ├── submission.json The document's record. Required.
│ ├── primary.pdf The document. May be missing.
│ ├── attachments/
│ │ └── <file name> Other files kept with the document.
│ ├── source.json What primary.pdf was converted from.
│ ├── annotations.json Highlights and their comments.
│ ├── reviews/
│ │ └── <review id>.json This reviewer's review.
│ ├── external-reviews.json Reviews imported from the web. Read-only.
│ ├── summary.json A cached summary of the document.
│ ├── digest.json A cached digest of the reviewer's marks.
│ ├── ink.json Reserved. Nothing reads or writes it.
│ └── ink/ Reserved. Nothing reads or writes it.
└── exports/ Dropped on save. Nothing writes it.
What the Mac does with each one:
| Path | Written | If absent | If it can't be read |
|---|---|---|---|
shelf.json |
every save | the shelf does not open | the shelf does not open |
settings.json |
every save | the kind's starting rubric is used, and written on the next save | the shelf does not open |
web-link.json |
every save, when the shelf has a link | an ordinary shelf | the shelf does not open |
agent-brief.json |
by other writers only, never the app | no brief | treated as no brief; the app keeps the file byte for byte |
assistant-policy.json |
by the person's own choice, never by an assistant (§5.5) | the kind's default (§5.5) | treated as off; TextShelf for Mac 1.0 doesn't read it and keeps the file byte for byte |
submissions/ |
every save | a shelf with no documents | (only its non-empty folders are read) |
submission.json |
every save | the document is left unopened (§4) | the document is left unopened |
primary.pdf |
when it changes | the document opens without its reading | same as absent |
attachments/ |
when it has files | no attachments | (only regular files inside are read) |
source.json |
by the app on adding a converted file, and by other writers | no record | treated as no record; the app keeps the file byte for byte |
annotations.json |
every save | no highlights | the document is left unopened |
reviews/ |
when the document has reviews | no reviews | one bad file leaves the document unopened |
external-reviews.json |
when the document has imported reviews | none | the document is left unopened |
summary.json |
when the document has one | none | the document is left unopened |
digest.json |
when the document has one | none | the document is left unopened |
3. Shared rules
3.1 How the field tables read
- required: a missing key, or a value of the wrong type, fails the file.
- optional: the key may be missing. A value of the wrong type still fails
the file. A value in brackets, as in optional (
[]), is what a missing key means; the Mac always writes those keys. It leaves out an optional key that has no value. - lenient (X): a missing key, or a value that can't be read, gives X. It never fails the file.
What failing a file costs is in §3.8.
3.2 Identifiers
A UUID is a string in the 8-4-4-4-12 hex form. The Mac writes uppercase. Readers accept either case.
A document's folder is named by its id, and a review's file by the review's
id. On read, the name doesn't matter: the id inside the file wins. On save,
the Mac writes submissions/<ID>/ and reviews/<ID>.json in uppercase and
removes any other name.
A SHA-256 is 64 lowercase hex digits.
3.3 Dates
There are two conventions. Which one applies depends on the file.
| Files | Read with | What a reader accepts | What the Mac writes |
|---|---|---|---|
shelf.json, web-link.json, submission.json, annotations.json, reviews/*.json, external-reviews.json, summary.json, digest.json |
ShelfCore's ShelfJSON.decoder |
ISO 8601 date and time with fractional seconds, Z or a ±hh:mm offset. Fractions are kept to the millisecond. A date with no fraction fails. |
UTC, three digits: 2026-10-12T09:47:03.215Z |
source.json, agent-brief.json |
Foundation's .iso8601 strategy |
ISO 8601 date and time in whole seconds. On macOS 27.0.1 it also accepted a fraction; older systems were not checked. | UTC, whole seconds: 2026-10-12T08:12:31Z |
(settings.json has no dates.)
In short: the bundle files require fractional seconds on read, and
source.json and agent-brief.json use Foundation's plain .iso8601.
Making them agree would be a change to the Mac code, and is not part of this
spec.
What an unreadable date costs:
- A required date (
createdAt,submittedAtand the like) fails its file. userModificationDateon a highlight, a comment or a review falls back tocreatedAtorsubmittedAt.provenance.generatedAtmakes the wholeprovenanceunreadable, and an unreadable provenance is read as human (§6.5). Write the fraction.- A bad date in
source.jsonoragent-brief.jsonmakes that file read as absent.
The Mac rounds every date it holds to the millisecond, so a date it writes reads back equal.
3.4 Numbers and score values
- Counts, indexes, bounds and web ids are JSON integers. A number with a fractional part fails where an integer is expected.
geometry.lineRectsinannotations.jsonis the only place with fractional numbers.- A score value is an integer or a string, nothing else. That covers
scoresinreviews/*.jsonandexternal-reviews.json,baselineinweb-link.json, andchoices[].valueinsettings.json. A float, a boolean ornullfails: inchoicesit costs the field its labels; anywhere else it fails the file.
3.5 Unknown keys
A reader ignores a key it doesn't know. The Mac does not keep it: on each save it rewrites every JSON file it models from what it decoded, so an unknown key is gone after the next save. A key added by a later build is lost when an earlier build saves the shelf.
agent-brief.json and source.json are kept byte for byte by the app,
because it treats them as unknown files (§3.6). Another writer that rewrites
source.json may drop the keys it doesn't know.
3.6 Unknown files and folders
- At the bundle root and in each document folder, a child the reader doesn't know is kept byte for byte across read and save. A folder is kept whole.
exports/at the root is dropped on save.- Symbolic links are dropped. So is a file whose contents can't be read.
- Inside
submissions/, only non-empty folders are documents. A file or an empty folder there is dropped on save. - Inside
attachments/, only regular files are kept. A folder there is dropped on save. - Inside
reviews/, every regular file is read as a review, whatever its name. A stray file there (a.DS_Store, say) leaves the document unopened. Put nothing else inreviews/. - A name the reader writes itself always wins over a kept child of the same name.
3.7 schemaVersion and kind
Every JSON file has an integer schemaVersion at its root. The current
version is 1. It is the only version field; no file has another.
| File | Checked | What a value above 1 does |
|---|---|---|
shelf.json, settings.json, web-link.json |
yes | The shelf does not open. The error reads: "<file> has schemaVersion N; this build supports up to 1. Update the app to open this shelf." |
submission.json, reviews/*.json, external-reviews.json, summary.json, digest.json |
yes | That document is left unopened (§4), with the reason "It was saved by a newer version of TextShelf. Update the app to open it." The rest of the shelf opens. |
annotations.json |
no | Nothing stops. The file is read as version 1, and the next save rewrites it with the number it had, without the keys this reader doesn't know. |
source.json, agent-brief.json |
no | Nothing stops. The key must be present; its value is not looked at. |
assistant-policy.json |
by its readers | A reader that meets a version above 1 reads the shelf as off (§5.5). |
Only values above 1 are refused; 0 or a negative number passes. Writers write
1. The Mac writes back the number it read. The App Intents catalog reads
shelf.json and each submission.json without checking the version; it
writes nothing.
Four files also carry a kind string:
| File | kind |
Checked |
|---|---|---|
web-link.json |
textshelf-web-link |
yes. Any other value fails the file, and the shelf does not open. |
source.json |
textshelf-document-source |
no. The key must be present; its value is not looked at. |
agent-brief.json |
textshelf-agent-brief |
no. The key must be present; its value is not looked at. |
assistant-policy.json |
textshelf-assistant-policy |
by its readers. Any other value reads as off. |
3.8 How far a failure reaches
- A root file that can't be read (
shelf.json,settings.json,web-link.json) stops the whole shelf from opening. - A document file that can't be read leaves that one document unopened (§4).
source.jsonandagent-brief.jsonthat can't be read count as absent.
No reader skips a bad element and keeps the rest of its file. One bad highlight, comment, review, imported review, web-link entry or rubric field fails its whole file. The tolerance the code has is per key: the keys marked lenient in the tables. A writer must not write an element that current readers can't decode.
3.9 How the Mac writes JSON
Bundle files: pretty-printed with two-space indents, keys sorted, / not
escaped. source.json and agent-brief.json: pretty-printed, keys sorted,
/ escaped as \/.
Two arrays are sorted before writing, so an unchanged shelf saves to the same
bytes: web-link.json entries by sourcePk, then submissionId;
external-reviews.json reviews by submittedAt, then externalReviewId.
Other arrays keep their order.
4. A document that can't be read
A document is left unopened when its submission.json is missing or bad, when
annotations.json, summary.json, digest.json or external-reviews.json
is bad, when any file in reviews/ is bad, or when a checked file has a
schemaVersion above 1.
Then the Mac:
- opens the rest of the shelf;
- lists the document with a reason ("Part of it (
<file>) is damaged.", "Part of it (<file>) is missing." or the newer-version reason in §3.7); - leaves its folder byte for byte on every save, including files that arrive after the shelf was read.
A missing or unreadable primary.pdf does not leave a document unopened.
5. Root files
5.1 shelf.json
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. |
id |
UUID | required | Made with the shelf. Never changes. A copy of a shelf gets a new one. |
name |
string | required | The display name. Writers keep it equal to the bundle's file name without .textshelf. The app takes the name from the file name when it opens a shelf and when the file is renamed, and writes it on the next save. A reader that has the path should prefer the file name. |
type |
string | required | The kind of shelf, using the web's workspace_type strings. The Mac offers literary_contest, grant_program, academic_review and custom. Any other string opens as custom and is written back unchanged, until someone picks a kind. |
createdAt |
date | required | When the shelf was made. |
modifiedAt |
date | required | Moved forward when the shelf is edited. |
A shelf built from a review packet takes the program's kind, which may be any
of the web's strings. Per
docs/native-shelf-types-plan.md, builds up to and including 50 read type
strictly and fail on any other string.
{
"createdAt" : "2026-07-20T16:41:07.392Z",
"id" : "6F1C2A84-3B5E-4D0A-9C7E-2F4B8D1E6A30",
"modifiedAt" : "2026-10-12T09:47:03.215Z",
"name" : "Spring 2026 Fiction Prize",
"schemaVersion" : 1,
"type" : "literary_contest"
}
5.2 settings.json
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. |
rubric |
object | required | {"fields": [...]}. fields is required. |
seededFieldIDs |
array of strings | optional | The field ids the kind's starting rubric wrote when the shelf was made, in order. When someone picks another kind, the app replaces the rubric only if it is empty, or if its field ids still equal this list and no review, imported review or highlight uses them. Absent when the shelf never had a starting rubric. A writer that sets a new rubric removes it. |
Each element of rubric.fields:
| Key | Type | Meaning | |
|---|---|---|---|
id |
string | required | Stable. Review scores and highlights' criterionIDs point at it, so it survives a relabel. The rubric editor makes field_<hex> ids; starting rubrics use the ids in workspace-packs.md. |
label |
string | required | Shown to the reviewer. |
type |
string | required | score or text. Any other value fails the file, and the shelf does not open. |
min |
integer | optional | Lowest score. The app uses 1 when absent. |
max |
integer | optional | Highest score. The app uses 5 when absent, and never less than min. |
choices |
array | lenient (none) | A labelled scale: [{"value": ..., "label": "..."}], both required. value is an integer for a score field and a string for a text field; it is what the review stores. One unreadable choice drops all of them. The app writes it only on a shelf built from a review packet. |
help |
string | lenient (none) | Guidance shown under the label. |
With no settings.json, the app uses the kind's starting rubric (none for
custom) and writes it, with seededFieldIDs, on the next save.
A shelf made with the literary contest rubric:
{
"rubric" : {
"fields" : [
{
"help" : "Language that sounds like this writer and no one else: controlled, specific, and alive on the page, or in the mouth for a play.",
"id" : "voice",
"label" : "Voice",
"max" : 5,
"min" : 1,
"type" : "score"
},
{
"help" : "The piece builds. Scenes, sections or stanzas earn their place, and the ending lands.",
"id" : "structure",
"label" : "Structure",
"max" : 5,
"min" : 1,
"type" : "score"
},
{
"help" : "Why it matters: the desire, tension or pressure that keeps a reader leaning in.",
"id" : "stakes",
"label" : "Stakes",
"max" : 5,
"min" : 1,
"type" : "score"
},
{
"help" : "A finished draft: precise word choice, rhythm in the prose, verse or dialogue, formatting suited to the form, few distracting errors.",
"id" : "craft_and_finish",
"label" : "Craft and finish",
"max" : 5,
"min" : 1,
"type" : "score"
},
{
"id" : "notes",
"label" : "Notes",
"type" : "text"
}
]
},
"schemaVersion" : 1,
"seededFieldIDs" : [
"voice",
"structure",
"stakes",
"craft_and_finish",
"notes"
]
}
A shelf built from a review packet:
{
"rubric" : {
"fields" : [
{
"choices" : [
{
"label" : "No",
"value" : 1
},
{
"label" : "Maybe: worth further consideration",
"value" : 2
},
{
"label" : "Yes: strongly recommend",
"value" : 3
}
],
"id" : "web-move-forward",
"label" : "Recommend further consideration?",
"max" : 3,
"min" : 1,
"type" : "score"
},
{
"help" : "Describe any conflict with this entry. Leave empty if there is none.",
"id" : "web-coi",
"label" : "Conflict of interest",
"type" : "text"
},
{
"id" : "web-notes",
"label" : "Notes",
"type" : "text"
}
]
},
"schemaVersion" : 1
}
5.3 web-link.json
Present only on a shelf built from a web review packet (review-packet.md). It ties each document to its web entry. While it is there, the app shows the source on the shelf's cover, locks the rubric editor and offers File ▸ Export Scores for Web…, and other writers should leave the shelf alone. A copy made with Send a Copy never carries it.
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. |
kind |
string | required | Must be textshelf-web-link. |
source |
object | required | Where the packet came from. |
source.host |
string | required | The web host that issued the packet. The return file sends it back. |
source.contestId |
integer | required | The web contest's id. The return file sends it back. |
source.contestSlug |
string | required | The contest's slug. Also stored as each document's externalSource, when not empty. |
source.contestName |
string | required | The contest's name. A new packet shelf is named after it. |
source.issuedAt |
date | required | When the packet was issued. The import time, if the packet didn't say. |
source.uploadUrl |
string | optional | The web page that takes the return file. The app offers it only when it is on host and passes the app's link rules (https, on a host the app fetches packets from). |
entries |
array | required | One per document that came from the packet. A document added by hand has none and is never exported. |
entries[].submissionId |
UUID | required | The document this entry became. |
entries[].sourcePk |
integer | required | The web entry's id. |
entries[].externalId |
string | optional | The web entry's number. Also stored as the document's externalID. |
entries[].bytes |
integer | required | The size of the file the packet declared. |
entries[].sha256 |
string | optional | The file's SHA-256, as the packet gave it. |
entries[].assistantPolicy |
string | optional | What the reader's own AI assistant may do with this document, as the packet's entry gave it: off, passages or drafts (review-packet.md). A missing or unknown value is off. TextShelf for Mac 1.0 doesn't keep it yet, which leaves the document at off. |
entries[].baseline |
object | optional ({}) |
The reviewer's web answers when the packet was issued, by rubric field id: field id → score value. Only fields this shelf's rubric has. The return file copies it, and the web reads "in the baseline, missing from the values" as cleared. |
source holds the packet's source minus workspaceType, which becomes
shelf.json's type.
{
"entries" : [
{
"baseline" : {
"web-assessment" : 4,
"web-notes" : "Strong opening."
},
"bytes" : 1834221,
"externalId" : "17",
"sha256" : "9f2c4e8a1b7d3f6e0c5a9b2d8e4f1a7c3b6d9e2f5a8c1b4d7e0f3a6c9b2e5d8f",
"sourcePk" : 91,
"submissionId" : "3D9C1A6E-7B2F-4C58-A9E4-1F6B3D8C2A70"
},
{
"baseline" : {
},
"bytes" : 402117,
"externalId" : "19",
"sourcePk" : 93,
"submissionId" : "5E2B8D4A-1C7F-4A93-B6E0-8D3F1A5C7E24"
}
],
"kind" : "textshelf-web-link",
"schemaVersion" : 1,
"source" : {
"contestId" : 42,
"contestName" : "Spring 2026 Playwriting Prize",
"contestSlug" : "spring-2026-playwriting",
"host" : "textshelf.com",
"issuedAt" : "2026-09-20T14:00:00.000Z",
"uploadUrl" : "https://textshelf.com/submissions/scores/42/upload/"
}
}
5.4 agent-brief.json
What the shelf is for, in an agent's words: its task, what each highlight category means for that task, and scoring notes. An agent's tool writes it and reads it back at the start of the next session, so that session reads with the same lens. The app does not read it and keeps it byte for byte. Send a Copy keeps it with Everything and leaves it out with Documents only.
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. Not checked. |
kind |
string | required | textshelf-agent-brief. Not checked. |
task |
string | required | What the shelf is for. Never empty. |
categoryGuide |
object | optional | Highlight category → what it means here. Keys are note, suggestion, question, praise or issue; write no other. |
notes |
string | optional | Anything else, such as how to score. Left out when empty. |
updatedAt |
date | required | When the brief was last set. Whole seconds (§3.3). |
If any of this can't be read, a reader treats the shelf as having no brief. A writer replaces the whole file.
{
"categoryGuide" : {
"issue" : "A flaw that would cost the story a place on the shortlist.",
"praise" : "What a judge would quote in the citation.",
"question" : "Something a second reader should check."
},
"kind" : "textshelf-agent-brief",
"notes" : "Score from the passages you mark; leave Notes for the overall case.",
"schemaVersion" : 1,
"task" : "Read each story as a first-round reader for the Spring 2026 Fiction Prize. Mark the passages each score rests on.",
"updatedAt" : "2026-10-12T08:10:00Z"
}
5.5 assistant-policy.json
What readers' own AI assistants may do with the shelf's documents: the assistant a person brings, which reads the shelf through some other program. TextShelf itself never sends a document to an AI service. The person who keeps the shelf sets the rule; an assistant's tool never does, and never changes it.
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. |
kind |
string | required | textshelf-assistant-policy. |
level |
string | required | off: an assistant doesn't read the documents. passages: it may read them and mark passages as evidence for the rubric, each marked as its own (§6.5), and writes no review. drafts: it may also draft a review, which counts only once the person adopts it. |
setAt |
date | required | When the person set it. Whole seconds (§3.3). |
setBy |
string | optional | Who set it, as the app names them. |
How a reader decides the rule for a document:
- The shelf's level. From this file. If the file is missing, the kind's
default:
draftson acustomshelf (the person's own reading),offon every other kind. If it is unreadable, has a higherschemaVersion, anotherkindor an unknownlevel:off. - A packet's word. On a shelf with
web-link.json, a document from the packet is held to its entry'sassistantPolicytoo (§5.3; missing isoff). The stricter of the two applies: the web's rule was what the document's writer read before sending it. - Strictest wins. The order is
off, thenpassages, thendrafts.
Any program that hands a document's text to an AI assistant follows the
document's rule, and reading counts: at off it doesn't read the document's
text into a model at all, nor its highlights' quoted words.
{
"kind" : "textshelf-assistant-policy",
"level" : "passages",
"schemaVersion" : 1,
"setAt" : "2026-10-13T09:00:00Z",
"setBy" : "Ada Reader"
}
6. Document files
Each document is a folder in submissions/.
6.1 submission.json
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. |
id |
UUID | required | The document's id, and its folder's name on save. |
title |
string | required | |
author |
string | optional | |
status |
string | required | unread, in_review or reviewed. Any other value fails the file. |
externalID |
string | optional | The document's number on the web host it came from. Set from a packet, or from a file name like 17 - Title.pdf when the file is added. Matches imported reviews to the document. |
externalSource |
string | optional | The contest slug externalID belongs to, so two contests' 17s stay apart. Set on packet shelves. |
createdAt |
date | required | Documents are listed in this order. |
modifiedAt |
date | required |
{
"author" : "M. Okafor",
"createdAt" : "2026-07-20T16:43:15.806Z",
"externalID" : "17",
"id" : "0B7E4C19-8A2D-4F36-B5C1-9E3D7A2F4C58",
"modifiedAt" : "2026-10-12T09:47:03.215Z",
"schemaVersion" : 1,
"status" : "in_review",
"title" : "The Lighthouse Keeper's Daughter"
}
6.2 primary.pdf
The document as a PDF. Highlights are anchored to its page text.
A file that isn't a PDF (Markdown, Word, RTF, OpenDocument, plain text) is
converted to a PDF once, when it is added, and the original is kept in
attachments/ (§6.3, §6.4). A locked PDF is stored unlocked.
If primary.pdf is missing or can't be read, the document still opens with
its highlights and reviews. The Mac leaves whatever is at that path alone on
save, and picks up a PDF that arrives there later.
6.3 attachments/
Other files kept with the document, by name, byte for byte. The app keeps the original of a converted document here. A writer that replaces a document keeps the earlier versions here (§6.4). The folder is written only when it has files, and holds regular files only.
6.4 source.json
What primary.pdf was made from, and each version the document has had. The
app writes it when it adds a converted file (converter TextShelf). Another
writer may write it when it adds a converted file or replaces a document's
version, and names itself in converter. Readers use it to tell "the same
file again" from a new document, and to point at lines of a Markdown or text
original. A document added as a PDF has no source.json until a writer
replaces its version.
ShelfDocument doesn't model the file: it is an unknown child to the bundle reader (§3.6), which is why the app keeps it byte for byte.
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. Not checked. |
kind |
string | required | textshelf-document-source. Not checked. |
converter |
string | required | Who made the record: TextShelf, or the name of the program that did. A later version doesn't change it. |
versions |
array | required | Oldest first. The last is what primary.pdf was made from. |
versions[].filename |
string | required | The file's name when it was added. A converted original is kept in attachments/ under this name. |
versions[].format |
string | required | pdf, markdown, word, richText, openDocument or plainText. Any other value makes the whole file unreadable. |
versions[].sha256 |
string | required | SHA-256 of the file as it was added. |
versions[].addedAt |
date | required | Whole seconds (§3.3). |
A writer that replaces a document that has no record starts one whose first
version is {"filename": "primary.pdf", "format": "pdf", ...}, dated with the
document's createdAt. A replaced PDF is kept in attachments/ as
<title> (version N).pdf, where N counts the versions before the new one. A
converted original whose name is taken by different bytes moves the older file
to <stem> (version N).<ext>. If such a name is taken too, a number is added
(<stem> (version N) 2.<ext>).
If the file can't be read, readers act as if it were absent. A writer then starts a new record when it next replaces the document, and writes it over the old file.
{
"converter" : "TextShelf",
"kind" : "textshelf-document-source",
"schemaVersion" : 1,
"versions" : [
{
"addedAt" : "2026-07-20T16:43:15Z",
"filename" : "17 - The Lighthouse Keeper's Daughter.md",
"format" : "markdown",
"sha256" : "3b7e9c1d5a2f8e4b6c0d9a3f7e1b5c8d2a6f4e9b3c7d1a5e8f2b6c0d4a9e3f7b"
},
{
"addedAt" : "2026-10-12T08:12:31Z",
"filename" : "17 - The Lighthouse Keeper's Daughter (revised).md",
"format" : "markdown",
"sha256" : "e4a8c2f6b0d3e7a1c5f9b2d6e0a4c8f1b5d9e3a7c0f4b8d2e6a1c5f9b3d7e0a2"
}
]
}
6.5 annotations.json
The reviewer's highlights on primary.pdf, each with its comment thread.
Written on every save, even when empty.
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. Not checked (§3.7). |
annotations |
array | required | The highlights. One element that can't be read fails the file. |
Each highlight:
| Key | Type | Meaning | |
|---|---|---|---|
id |
UUID | required | |
type |
string | required | Must be highlight. Any other value fails the file. New kinds of mark go in other files, never here. |
category |
string | lenient (note) |
note, suggestion, question, praise or issue. What the reviewer means by the mark. |
pageIndex |
integer | required | Page of primary.pdf, from 0. |
selector |
object | required | Where the highlight is (below). |
color |
string | required | yellow, green, blue, purple, red, pink or orange; any other name draws yellow. Set from the category when the highlight is made: note yellow, suggestion blue, question purple, praise green, issue red. |
createdAt |
date | required | |
userModificationDate |
date | lenient (createdAt) |
Last-writer-wins key for sync. Each edit must move it forward, by at least a millisecond. |
resolved |
boolean | lenient (false) |
Whether the thread has been dealt with. |
authorName |
string | lenient ("") |
Display name of whoever made it. Empty means unknown. |
provenance |
object | lenient (human) | Who made it, a person or an assistant. See "Provenance" below. |
criterionIDs |
array of strings | lenient ([]) |
Rubric field ids this passage bears on, in order. An id the rubric no longer has is kept and shown as unknown. |
comments |
array | optional ([]) |
The thread, oldest first. One comment that can't be read fails the file. |
selector:
| Key | Type | Meaning | |
|---|---|---|---|
kind |
string | lenient (textQuote) |
The Mac writes textQuote. Any string is accepted and kept. |
exact |
string | required | The selected text, whitespace-normalized. Always the words the reviewer chose, whichever tier holds the anchor. |
prefix |
string | lenient (none) | Up to 32 characters of normalized page text just before exact. |
suffix |
string | lenient (none) | Up to 32 characters just after. |
lineQuotes |
array | lenient (none) | Tier 2: one quote per visual line, each {"exact", "prefix"?, "suffix"?}, exact required. Written when the whole quote isn't one run of page text but each line is. |
geometry |
object | lenient (none) | Tier 3: where the selection was drawn. pageIndex (integer) and lineRects, one {"x", "y", "width", "height"} per visual line, all required numbers. PDF page space as PDFKit reports it: points, origin at the bottom left, y up. The Mac records it for every new highlight. |
createdTier |
string | lenient (none) | wholeQuote, lineQuotes or geometry: the tier that held the anchor when it was made. Absent on records from before tiers, which are whole quotes. An unknown value reads as absent. |
Normalization: every run of whitespace, line breaks included, becomes one
space, and leading and trailing whitespace is removed. prefix and suffix
are cut from the normalized page text and may start or end with a space. The
page text is PDFKit's PDFPage.string. Another PDF library may extract
different text, and then a quote may not be found.
How the Mac finds a highlight:
- With
createdTierabsent orwholeQuote: it looks forexact, with itsprefixandsuffixaround it, in the normalized text of pagepageIndex. One match there wins. Several there are settled bygeometrywhen exactly one overlaps it; otherwise this step fails. Only when that page has no match does it look at the other pages, and there it needs exactly one match. - With a lower tier and
geometry: it searches forexacttoo, but takes a match only where it overlaps the stored rectangles. - Then
lineQuotes: every line must be found, on one page. - Then
geometry: it draws the stored rectangles that fall on the page. - Otherwise the highlight is shown as lost.
A writer that moves a highlight to a new version of a document and can't place
it there removes geometry, so the app shows the highlight as lost rather than
drawing it over different text.
Each comment:
| Key | Type | Meaning | |
|---|---|---|---|
id |
UUID | required | |
body |
string | required | |
authorName |
string | required | Display name. Required here, unlike on the highlight. |
createdAt |
date | required | |
userModificationDate |
date | lenient (createdAt) |
As on the highlight. |
provenance |
object | lenient (human) | As on the highlight. |
Provenance
provenance says whether a person or an assistant made a highlight or a
comment. It carries no content.
| Key | Type | Meaning |
|---|---|---|
authorKind |
string | human or assistant. |
model |
string, optional | The model that made it. |
promptVersion |
string, optional | Which prompt made it, in the writer's own terms. |
generatedAt |
date, optional | When the model call ran. Not the same as createdAt. |
The decode rule, as the bundle reader applies it:
- No
provenancekey: human. Every record written before the key existed was made by a person. - An object with no
authorKind: human. - An
authorKindother thanhumanorassistant: unknown. Unknown is never shown as human. - A
provenancethe reader can't decode at all: human. That covers a value that isn't an object, a non-stringauthorKind,modelorpromptVersion, and ageneratedAtwithout fractional seconds.
Rule 4 is weaker than docs/ai-provenance-shape.md, which says unparseable
provenance reads as unknown. That holds for the CloudKit record, not for the
bundle. A writer of assistant-made records must write a well-formed object.
Only human counts as a person's work. A writer acting for an agent marks
everything it writes assistant. It edits or removes only highlights that
aren't human, removes none a person has commented on, and may add comments
under any highlight. The app writes {"authorKind": "human"} on everything it
makes.
The example holds a person's resolved question, an assistant's highlight, and a highlight anchored by line quotes:
{
"annotations" : [
{
"authorName" : "Ruth Adler",
"category" : "question",
"color" : "purple",
"comments" : [
{
"authorName" : "Ruth Adler",
"body" : "Is the lamp failing, or is someone putting it out?",
"createdAt" : "2026-08-05T19:22:40.118Z",
"id" : "4E8B1C73-2D9A-4B65-8F3E-7C1A5D9B2E06",
"provenance" : {
"authorKind" : "human"
},
"userModificationDate" : "2026-08-05T19:22:40.118Z"
}
],
"createdAt" : "2026-08-05T19:22:40.118Z",
"criterionIDs" : [
"stakes"
],
"id" : "9D4A6E21-5C3B-4E8F-A1D7-3B6C9F2E8A41",
"pageIndex" : 2,
"provenance" : {
"authorKind" : "human"
},
"resolved" : true,
"selector" : {
"createdTier" : "wholeQuote",
"exact" : "the lamp gave one last yellow shudder before going dark",
"geometry" : {
"lineRects" : [
{
"height" : 13.5,
"width" : 468,
"x" : 72,
"y" : 512.25
},
{
"height" : 13.5,
"width" : 211.5,
"x" : 72,
"y" : 497.75
}
],
"pageIndex" : 2
},
"kind" : "textQuote",
"prefix" : "She turned the great brass key, ",
"suffix" : ", and the gulls went quiet on th"
},
"type" : "highlight",
"userModificationDate" : "2026-10-12T09:47:03.215Z"
},
{
"authorName" : "Claude",
"category" : "praise",
"color" : "green",
"comments" : [
{
"authorName" : "Claude",
"body" : "A first line that sets the stakes and the voice at once.",
"createdAt" : "2026-10-12T08:15:09.554Z",
"id" : "8B3D5F19-6A2C-4E71-9D4B-2F6A8C1E5D33",
"provenance" : {
"authorKind" : "assistant",
"generatedAt" : "2026-10-12T08:15:09.551Z",
"model" : "claude-opus-5",
"promptVersion" : "highlights-v1"
},
"userModificationDate" : "2026-10-12T08:15:09.554Z"
}
],
"createdAt" : "2026-10-12T08:15:09.554Z",
"criterionIDs" : [
"voice",
"structure"
],
"id" : "2A5F8C37-9E1B-4D62-8B4A-6C3E1F7D9B15",
"pageIndex" : 0,
"provenance" : {
"authorKind" : "assistant",
"generatedAt" : "2026-10-12T08:15:09.551Z",
"model" : "claude-opus-5",
"promptVersion" : "highlights-v1"
},
"resolved" : false,
"selector" : {
"createdTier" : "wholeQuote",
"exact" : "My father kept the light for thirty-one winters",
"geometry" : {
"lineRects" : [
{
"height" : 13.5,
"width" : 301.25,
"x" : 72,
"y" : 640.5
}
],
"pageIndex" : 0
},
"kind" : "textQuote",
"prefix" : "I. THE LOG ",
"suffix" : " and never once let it go out. H"
},
"type" : "highlight",
"userModificationDate" : "2026-10-12T08:15:09.554Z"
},
{
"authorName" : "Ruth Adler",
"category" : "issue",
"color" : "red",
"comments" : [
],
"createdAt" : "2026-08-05T19:30:02.871Z",
"criterionIDs" : [
],
"id" : "7C2E9B54-1F3A-4A8D-B6E2-5D8F2A1C3E79",
"pageIndex" : 4,
"provenance" : {
"authorKind" : "human"
},
"resolved" : false,
"selector" : {
"createdTier" : "lineQuotes",
"exact" : "Q. Where were you standing when the lamp went out? A. On the gallery, by the north window.",
"geometry" : {
"lineRects" : [
{
"height" : 12,
"width" : 318.5,
"x" : 108,
"y" : 430
},
{
"height" : 12,
"width" : 247.25,
"x" : 108,
"y" : 414
}
],
"pageIndex" : 4
},
"kind" : "textQuote",
"lineQuotes" : [
{
"exact" : "Q. Where were you standing when the lamp went out?",
"prefix" : "oud. 9 THE CORONER: Proceed. 10 ",
"suffix" : " 11 A. On the gallery, by the no"
},
{
"exact" : "A. On the gallery, by the north window.",
"prefix" : "ding when the lamp went out? 11 ",
"suffix" : " 12 Q. Alone? 13 A. Yes. 14 Q. A"
}
]
},
"type" : "highlight",
"userModificationDate" : "2026-08-05T19:30:02.871Z"
}
],
"schemaVersion" : 1
}
6.6 reviews/<review id>.json
The reviewer's own review, one file per review. Every regular file in
reviews/ is read as one (§3.6). The folder is written only when the document
has a review.
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. |
id |
UUID | required | Also the file's name. |
submissionId |
UUID | required | The document reviewed. |
reviewerName |
string | required | |
scores |
object | required | Rubric field id → score value: an integer for a score field, a string for a text field. A key the rubric no longer has is kept. |
submittedAt |
date | required | Shown as when it was submitted. The review form sets it on every save. |
userModificationDate |
date | lenient (submittedAt) |
Last-writer-wins key for sync. |
The app shows and edits one review per document: the one with the earliest
submittedAt. Another writer writes a review only when there is none or the
earliest is its own, and never replaces a person's.
{
"id" : "C3F7A9D2-6B14-4E8A-9D5C-1A7E3B8F4C92",
"reviewerName" : "Ruth Adler",
"schemaVersion" : 1,
"scores" : {
"craft_and_finish" : 4,
"notes" : "Strong opening; the middle third repeats the storm.",
"stakes" : 4,
"structure" : 3,
"voice" : 4
},
"submissionId" : "0B7E4C19-8A2D-4F36-B5C1-9E3D7A2F4C58",
"submittedAt" : "2026-10-12T09:40:18.660Z",
"userModificationDate" : "2026-10-12T09:46:55.013Z"
}
6.7 external-reviews.json
Reviews collected on the web and imported. Read-only in the app. They live
apart from reviews/ so the reviewer's own form never edits them. The file is
written only when the document has imported reviews. Format of the file they
are imported from: review-inbox.md.
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. |
reviews |
array | required | One element that can't be read fails the file. |
Each review:
| Key | Type | Meaning | |
|---|---|---|---|
externalReviewId |
string | required | Identity, and the key a re-import replaces by. Prefixed by the host software: django:<id>. |
reviewerName |
string | required | |
anonymized |
boolean | optional (false) |
|
submittedAt |
date | required | When it was submitted on the web. |
importedAt |
date | required | When it was imported. |
source |
object | required | Where it came from, on every review: host, contestSlug, contestName (strings) and exportedAt (date), all required. |
scores |
object | optional ({}) |
Web rubric slug → score value. A slug this shelf's rubric lacks is kept. |
scoreComments |
object | optional ({}) |
Slug → comment string. |
rubric |
array | optional ([]) |
The web rubric for those slugs, for display: slug and name (required strings), maxScore (optional integer). Weights are never stored. |
assessment |
integer | optional | The web's quick-evaluation fields, carried as they came. |
moveForward |
integer | optional | |
recommendation |
string | optional | |
confidence |
integer | optional | |
comment |
string | optional | |
hasCoi |
boolean | optional (false) |
Whether the reviewer declared a conflict of interest. |
coiDescription |
string | optional | |
complete |
boolean | optional (true) |
{
"reviews" : [
{
"anonymized" : true,
"assessment" : 4,
"comment" : "Strong opening; pacing flags in the middle third.",
"complete" : true,
"confidence" : 3,
"externalReviewId" : "django:1234",
"hasCoi" : false,
"importedAt" : "2026-08-03T17:12:44.031Z",
"moveForward" : 2,
"recommendation" : "accept",
"reviewerName" : "Reviewer 2",
"rubric" : [
{
"maxScore" : 5,
"name" : "Innovation",
"slug" : "innovation"
},
{
"maxScore" : 5,
"name" : "Craft",
"slug" : "craft"
}
],
"scoreComments" : {
"innovation" : "Fresh premise."
},
"scores" : {
"craft" : 3,
"innovation" : 4
},
"source" : {
"contestName" : "Spring 2026 Playwriting Prize",
"contestSlug" : "spring-2026-playwriting",
"exportedAt" : "2026-08-03T17:04:00.000Z",
"host" : "textshelf.com"
},
"submittedAt" : "2026-08-01T09:30:00.000Z"
}
],
"schemaVersion" : 1
}
6.8 summary.json
A cached summary of the document, made by a model. Builds with cloud summaries wrote it; that feature has been removed. The current app makes no new ones and shows none, but it reads the file, checks it and keeps it through saves. Written only when the document has one.
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. |
text |
string | required | The summary. |
keyPoints |
array of strings | required | |
concerns |
array of strings | required | |
model |
string | required | The model that wrote it. |
prompt |
string | required | The prompt's id, such as reviewer-prep-v1, not its text. |
generatedAt |
date | required | |
coverage |
object | optional | How much of the document the model saw: documentWordCount and summarizedWordCount (integers, both required). Absent on summaries from before it existed, where coverage is unknown. |
{
"concerns" : [
"The middle section repeats the storm description."
],
"coverage" : {
"documentWordCount" : 6140,
"summarizedWordCount" : 6140
},
"generatedAt" : "2026-08-07T10:11:12.345Z",
"keyPoints" : [
"The log book entries carry the plot.",
"The ending returns to the opening image."
],
"model" : "claude-sonnet-5",
"prompt" : "reviewer-prep-v1",
"schemaVersion" : 1,
"text" : "A keeper's daughter tends the light alone through one storm after her father's death. The story moves between his log book and her memory of him."
}
6.9 digest.json
A cached digest of what the reviewer's own highlights add up to, made by a
model from the highlights and the rubric labels. Same standing as
summary.json: no current build makes one, and the app keeps it. Written only
when the document has one.
| Key | Type | Meaning | |
|---|---|---|---|
schemaVersion |
integer | required | 1. |
overview |
string | required | What the marks show, in a few sentences. |
throughLines |
array of strings | required | Concerns that recur across highlights. |
untouchedCriteria |
array of strings | required | Rubric labels no highlight reaches. |
model |
string | required | |
prompt |
string | required | The prompt's id, such as reviewer-work-v1. |
generatedAt |
date | required |
{
"generatedAt" : "2026-08-07T10:14:40.902Z",
"model" : "claude-sonnet-5",
"overview" : "Your marks so far ask what the lamp scenes are for and flag the inquest transcript. Most of them sit in the first five pages.",
"prompt" : "reviewer-work-v1",
"schemaVersion" : 1,
"throughLines" : [
"Two questions ask who is keeping the light after the father's death."
],
"untouchedCriteria" : [
"Voice",
"Craft and finish"
]
}
6.10 ink.json and ink/ (reserved)
Reserved for ink marks: metadata in ink.json, one drawing per mark in
ink/<mark id>.drawing. Nothing reads or writes them. Current readers keep
them as unknown children (§3.6). The intended shape is in
docs/ink-annotation-record-shape.md. It is not part of v1.
7. Adding to the format
- A new optional key keeps
schemaVersionat 1. Readers must give it a default when absent. An earlier build drops it on save (§3.5). - New kinds of data go in new files. Earlier builds keep unknown files (§3.6).
- Never add a new kind of element to an existing array. Every reader in the field fails the whole file over one element it can't read (§3.8).
- Raise
schemaVersiononly for a change that makes an earlier reader wrong, not just incomplete: a redefined coordinate space, a renamed required key, a different date encoding. Every checked file then stops earlier readers. Note that todayannotations.json,source.jsonandagent-brief.jsonare not checked (§3.7). - A reader must be in the field before the first writer of an addition.
Where the code is
Paths are relative to the repository root. ShelfCore/ stands for
native/Packages/ShelfCore/Sources/ShelfCore/.
- Bundle read and write, known and dropped children, the per-document rule,
the version check:
ShelfCore/ShelfDocument.swift:111-124,:176-269,:273-384,:401-411,:455-526,:574-589. - What a save keeps, rewrites or leaves alone:
ShelfCore/ShelfBundleSnapshot.swift:34-72. - Unknown children:
ShelfCore/BundleNode.swift:17-68. - Date encoding and decoding for bundle files:
ShelfCore/CodingHelpers.swift:3-36. - The models (
Shelf,ShelfSettings,RubricField,Submission,SubmissionSummary,AnnotationFile,AnnotationProvenance,Annotation,TextQuoteSelector,LineQuote,AnnotationComment,Review,ScoreValue,ExternalReviewand its parts):ShelfCore/Models.swift:3-1035; the provenance rule at:490-538and:648-652. - Anchor tiers and geometry:
ShelfCore/Annotation/SelectorGeometry.swift:9-61. - Making and finding anchors:
ShelfCore/Annotation/AnnotationHelpers.swift:93-214,:292-351,:421-483; normalization:ShelfCore/Annotation/WhitespaceNormalization.swift:11-27. web-link.json:ShelfCore/ReviewPacket/WebLink.swift:17-116; itssource:ShelfCore/ReviewPacket/ReviewPacketManifest.swift:8-37; how a packet shelf is built:ShelfCore/ReviewPacket/PacketShelfBuilder.swift:136-229;workspaceTypeand a missingissuedAt:ShelfCore/ReviewPacket/ReviewPacketManifest.swift:219-220,:244-258; the upload page rule:ShelfCore/ReviewPacket/PacketLinkPolicy.swift:109-126.source.json:ShelfCore/Import/SourceRecord.swift:5-45; formats and hashes:ShelfCore/Import/DocumentConversion.swift:33-49,:64-66; the app writing it:ShelfCore/ShelfReferenceDocument+Ingest.swift:161-223.digest.json:ShelfCore/AI/AnnotationDigest.swift:10-42.- When the kind menu replaces a rubric:
ShelfCore/ShelfTypeChange.swift:22-34. - The name taken from the file:
ShelfCore/ShelfReferenceDocument.swift:92-97. - The review the app edits:
native/Packages/ShelfUI/Sources/ShelfUI/ReviewForm.swift:40-43. - Colour names:
native/Packages/ShelfUI/Sources/ShelfUI/ShelfTheme.swift:37-48,:77-89. - Copies:
ShelfCore/ShelfCopy.swift:41-97. - The App Intents catalog:
ShelfCore/Intents/ShelfCatalog.swift:83-111. - Errors:
ShelfCore/Errors.swift:3-18. - The document type:
native/TextShelf/project.yml:114-122.
This specification is licensed under CC BY 4.0; the example files under MIT. Anyone may implement these formats in their own software.
This specification is licensed under CC BY 4.0, and its example and schema files under the MIT licence. Anyone may implement these formats in their own software. CC BY 4.0