Annotations — as built

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.

6 August 2026 · implemented · builds clean · loop verified end to end

What changed from the plan you approved

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.

The shape

ANNOTATING mode the draft a filed set ───────────────── ───────── ─────────── click any object ──▶ annotations/draft.json ──▶ annotations/<id>.json ◀── session entry · section annotations/<id>.html ──▶ you mini game · brief clears ONLY the guide itself on filing

Three properties carry the design:

  1. Filing is the only thing that clears the draft, and it clears it into a file that persists. Nothing is ever lost.
  2. Filing does not clear the markers. They count open items across the draft and every filed set, so a pass you filed but never worked still shows on Day 3. Without this, filing would just move the original failure up a level.
  3. The session can propose, never close. It writes status and appends to a thread; it's told in as many words not to delete an item or the set.

On disk

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:

The item

{
  "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.

Why the session gets the JSON

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.

Mode

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:

Annotating survives on a frozen guide

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.

Files

NewWhat
Models/AnnotationModels.swiftThe item, target, kind, status, message, set, ref
Services/AnnotationStore.swiftLoad / file / mutate / delete, plus AnnotationLabels — the one place a target becomes words
Services/AnnotationSetRenderer.swiftSet → HTML, styled to match this shelf
Views/Document/NoteViews.swift.annotatable, the marker, the composer
Views/AnnotationsSheet.swiftThe panel: draft, filing, filed sets, threads, status

Modified: DocumentState, GuideBar, GuideDocumentView, EntryDocumentView, MiniGameViews, RegionViews, ItineraryViews, ManualViews, NavigatorView, CorpusStore, SessionLaunchers, SessionLauncherSheet, AppMainView, Press_RavenApp.

One performance fix worth knowing about

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.

The contract's operative paragraph

"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.

Verification

CheckedHowResult
App buildsxcodebuild -schemesucceeded
JSON contract round-tripsHarness over the real model file, 14 assertionsall pass
Session writeback decodesstatus, closed, thread, authorpass
One malformed item doesn't sink a setper-item decode, deliberately bad kind2 of 3 kept
Markers render while readingran 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 livewrote draft.json with the app openappeared · git 2 → 3 uncommitted
Filing does not clear markersfiled by hand, cold relaunchstill "4 open"
Writeback drops the countset 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.

Not verified

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.

Known limits

  1. ⌘' 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.
  2. 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.
  3. Read-modify-write, not locking. The app re-reads a set from disk before mutating, so a session's work isn't clobbered from a stale copy — but the window is narrowed, not closed. Two machines editing one set produce a plain git conflict, and the corpus has no handling for that outside entry files.
  4. Deleting a set with open items discards them. Warned in the tooltip and nowhere else — no confirmation dialog.
  5. Brief notes are whole-brief. Per the entry-level floor. BriefSlotView already carries (sectionID, key) if that chafes.
  6. A renumbered day orphans its section notes, since the group key changes. The label falls back to the raw key rather than misreporting.