Notes left while reading, filed as a durable set that a Claude session reads, works and answers item by item — without being able to close anything.
Your model replaced mine wholesale, and it was the right call. The fingerprint subsystem — SHA-256 canonical hashing, drift detection, rebaselining, orphan sweeps — is gone entirely, because once the filed set rather than the individual note became the thing that persists, none of it was needed.
One amendment held: the session edits the JSON, not the HTML. The page is regenerated from the data on every change.
Three properties carry the design:
status and appends to a thread; it's told in as many words not to delete an item or the set.guides/<id>/annotations/draft.json the unfiled pass
guides/<id>/annotations/<set-id>.json a filed set — the session edits THIS
guides/<id>/annotations/<set-id>.html the same set, rendered for reading
Every reason for that location was checked against the code, not assumed:
guides/, so CorpusGit.Change.area attributes it to the right guide — commit sheet and scoped commits work with no wiring.entries/ — handleExternalChanges routes any .json inside a directory named entries to reloadEntry, which infers a guide id from the parent-of-parent directory. A note file there would load as an entry document..json under /guides/, an external write triggers the debounced reload — which is how a session's answer appears live.CorpusStore.registerWrite(_:) first, so the FSEvents echo is suppressed. Without it each note would cost a full corpus reload — the bug importDesignSession still has.{
"id": "n-20260806-1412-a3f",
"kind": "fix",
"target": { "type": "entry", "entry": "arrival" },
"body": "Six hours with a charge stop reads optimistic for a Sunday.",
"created": "2026-08-06",
"status": "open",
"closed": null,
"thread": [
{ "author": "claude", "body": "Checked: 5h50m typical…", "at": "2026-08-06" }
]
}
kind — fix expects the material to change; ask expects an answer in the thread and usually no edit at all. The contract tells the session not to invent a change to make an ask look addressed.
status — open → done | declined. A declined item is a real answer, not a failure: a session that read a concern, disagreed and said why has done the work, and folding that into either of the other two would hide it.
target.type — guide · brief · section · mini_game · entry. Entry-level floor, as you chose. Each is an identity the document already addresses itself by, so a note can't point somewhere the document can't go.
The page is a rendering, rebuilt from the data on every change, and the session is handed the absolute path to the JSON with an explicit instruction not to touch the HTML beside it.
Had the session edited the page instead: the app couldn't read how many items were outstanding, so the guide-bar chip and every document marker would be blind; the markup would degrade a little each pass; and two writers would be merging tags.
Handing over an absolute path also means a game session can answer — it opens in the app repo, but an absolute path reaches the corpus regardless. That removes the "games get no writeback" weakness from the earlier design.
readMode: Bool was removed rather than shimmed, so all 37 call sites had to be revisited. Two needed !isReading rather than isSculpting, and both would have been silent bugs behind a Bool:
GuideDocumentView:111 — the mini game band, hidden while reading. It's the only surface a mini game can be annotated from.EntryDocumentView:74 — the compact seed's note, replaced by "Forthcoming." while reading. That note is usually the thing you're annotating about.SCULPTING drops out of the pill when a guide is final. ANNOTATING doesn't — a note changes nothing about the artifact, and noticing a problem in something you already shipped is exactly when you want to record it.
⌥⌘R cycles (skipping sculpt when the guide isn't editable). ⌘' annotates whatever holds the cursor from any mode, so a thought arriving mid-sculpt doesn't cost you the mode you're in. ⇧⌘' opens the panel.
| New | What |
|---|---|
Models/AnnotationModels.swift | The item, target, kind, status, message, set, ref |
Services/AnnotationStore.swift | Load / file / mutate / delete, plus AnnotationLabels — the one place a target becomes words |
Services/AnnotationSetRenderer.swift | Set → HTML, styled to match this shelf |
Views/Document/NoteViews.swift | .annotatable, the marker, the composer |
Views/AnnotationsSheet.swift | The panel: draft, filing, filed sets, threads, status |
Modified: DocumentState, GuideBar, GuideDocumentView, EntryDocumentView, MiniGameViews, RegionViews, ItineraryViews, ManualViews, NavigatorView, CorpusStore, SessionLaunchers, SessionLauncherSheet, AppMainView, Press_RavenApp.
Every entry row in both the document and the navigator asks "are there notes on me?" on each render. Answering by scanning the draft and every set is O(entries × notes) per pass — fine for a 61-stop itinerary, not fine for Stellar's 430. Open refs are bucketed by target key in an index that rebuilds on load and on mutation.
"status is done when you did the work, declined when you read it and disagree. A declined item is a real answer, not a failure. Leave status as open for anything you could not reach, and say in the thread what stopped you. Do not delete an item, do not add items, and do not delete the set. Whether a pass is finished is the author's call — you account for each item, they close it."
Filing goes straight into the launcher preloaded with the set just filed. Filing and handing over are one intent, and splitting them into two clicks in two places is how a filed set sits unworked.
| Checked | How | Result |
|---|---|---|
| App builds | xcodebuild -scheme | succeeded |
| JSON contract round-trips | Harness over the real model file, 14 assertions | all pass |
| Session writeback decodes | status, closed, thread, author | pass |
| One malformed item doesn't sink a set | per-item decode, deliberately bad kind | 2 of 3 kept |
| Markers render while reading | ran against the real corpus | !2 on the entry, ? on the brief, ! on Day Two, ✎2 in the navigator, ✎4 open in the bar |
| External write picked up live | wrote draft.json with the app open | appeared · git 2 → 3 uncommitted |
| Filing does not clear markers | filed by hand, cold relaunch | still "4 open" |
| Writeback drops the count | set 1 done + 1 declined externally | "4 open" → "2 open" live |
Test data went into washington-dc and asheville-2026 and was removed; the corpus is back to its prior state.
The in-app click paths — composing a note, File as Set, the status menu, replies, Open Page, Delete Set. Synthetic clicks were blocked (osascript is not allowed assistive access), so every path was exercised through the files instead. The file-level behaviour those buttons drive is confirmed; the buttons themselves are not.
⌘' needs its target on screen. The popover anchors to the annotated view and the document is a LazyVStack, so an entry scrolled far out of view isn't realized and nothing appears. In practice you're annotating what you're looking at.Codable drops unknown keys. A field a session invents is lost on the next app write. Per-item decoding stops one bad item taking down a set, but a well-formed unknown key still goes silently.BriefSlotView already carries (sectionID, key) if that chafes.