Skip to content

Highlights: Kindle-style passage highlights with attached notes — a durable annotation store beside the disposable index #166

Description

@samkeen

Kindle-style highlights for notes: select a passage, highlight it, optionally attach a note (an annotation) about it. Highlights are durable — editing the rest of the note doesn't break them, and a highlight follows its text through light edits — and two highlights over the same text in two different locations remain distinct, permanently.

Decisions below were settled in the spec session of 2026-08-12; the open questions at the bottom are the ones that weren't.

The decision that shapes everything: highlights live in a durable annotation store, not the Markdown

Decided: highlights + annotations are stored in the database, not the note's Markdown, and rendered on demand — inline tint, a notes side panel, or footnote-style at the bottom of the reading view (render-time only; nothing is ever written into the body). This is a deliberate amendment to the invariant register: the first durable B2-managed state outside the Markdown (S4 today says none exists).

Why neither Markdown home fits:

  • Inline ==marks== in the body — B2 splicing body bytes breaks W2/W3, and the attached annotation would need further body syntax (comments/footnotes): the "special syntax tax" data-model.md §7 already rejects.
  • Frontmatter b2_highlights: (the b2_relations: shape) — keeps S4 intact, but the payload is wrong for frontmatter: an anchor is machine data (verbatim quote + context windows + offsets) no human should hand-maintain, and a heavily-annotated note would grow a frontmatter block dwarfing its body. Annotations are about the document, not document content.

The accepted consequence: another editor (Obsidian, cat) sees clean Markdown and no highlights. That is the trade — a pristine file over annotation portability — and the docs should state it.

The store: a second durable file, so the index stays disposable

Proposed: <vault>/.b2/annotations.sqlite, separate from b2.sqlite. The line to hold is that b2.sqlite stays 100% disposable — "delete the index and reindex" must remain a safe recovery move, and S5 (schema change = version bump + rebuild) must keep applying to it verbatim. Durable tables inside b2.sqlite would silently break both. The annotation store is the opposite kind of artifact:

  • Durable → it gets real, versioned migrations — exactly the thing S5 proudly avoids for the index. This is a new engineering obligation and the true recurring cost of the feature; it deserves its own module and tests.
  • Authoritative for highlights, the way the vault directory is authoritative for documents.
  • Concurrent under the C1 discipline: readers unrestricted, writers serialized on SQLite's own write lock; both adapters (CLI and desktop) read and write it.
  • Part of the backup/portability story: the Markdown alone is no longer the whole vault. Docs must say .b2/annotations.sqlite travels with the vault; b2 highlights --json (below) is the export escape hatch.

Invariant amendments (docs are the source of truth — amend them first)

  • S1/S2 — two tiers become two authoritative sources, one projection: index = projection of (the vault directory, the annotation store). Drop b2.sqlite, reindex, get an identical index — still true.
  • S4 — amended, narrowly: highlights are B2-managed durable state living in the annotation store. Still no sidecars beside notes (L3 untouched), still no event log, still nothing authored hiding in the disposable index.
  • S5 — unchanged for the index; explicitly does not extend to the annotation store, which migrates instead of rebuilding.
  • W series — every annotation-store write is the mechanics of an explicit command (create / edit annotation / delete / re-pin). W4 extends: an orphaned highlight is surfaced, never auto-deleted.
  • C1 — extends to the annotation store.

invariants.md, data-model.md (new section: the highlight object, and a "rejected alternatives" entry for the two Markdown homes), and index-engine.md (store DDL + projection flow) all get amendments in the same PR wave as the code.

Data model: the highlight

One record in the annotation store:

  • id — ULID via IdGen (E1: injected, deterministic in tests).
  • note_b2id — the annotated note, keyed by identity, never path (L1's logic) → a move/rename touches zero highlight rows.
  • Anchor — W3C-Web-Annotation-flavored TextQuoteSelector plus a position hint, all relative to the body (frontmatter excluded, so a frontmatter edit can't shift offsets):
    • exact — the highlighted text, verbatim;
    • prefix / suffix — ~32 chars of body context either side;
    • pos_hint — char offset of exact at creation time.
  • annotation — optional Markdown text (a bare highlight with no note is legal).
  • created / updated — injected timestamps (E1).

Distinctness (the same-text-twice requirement): identity is the id, never the anchor. Two highlights over identical text carry different prefix/suffix/pos_hint and always different ids; even a pathological identical-context duplicate stays a distinct row. Resolution assigns each to a distinct occurrence (below).

Anchoring: resolution & durability

Decided: fuzzy re-anchor + orphan, with explicit human re-pin. Resolution is a pure text function in b2-core (no model, no clock — fully unit-testable in the fast suite), run at read time against the current body:

  1. Exact with context — find prefix + exact + suffix. One hit → anchored.
  2. Exact, disambiguated — find all occurrences of exact; score by context similarity and distance from pos_hint. Same-text highlights are resolved jointly, each occurrence consumed once, so two of them can never collapse onto one occurrence.
  3. Fuzzy — windowed approximate match (edit-distance similarity) seeded near pos_hint, so the highlight follows lightly-edited text.
  4. Below the similarity threshold → orphan: kept, listed with its quote + annotation, surfaced per the W4 posture — never auto-deleted, never silently dropped. The human re-pins (selects the passage again) and B2 rewrites the anchor on that command.

B2 never rewrites a stored anchor of its own accord in v1. Noted for later: because the anchor lives in B2's own store — not the human's Markdown — auto-refreshing it after a confident fuzzy match would not be a vault write; the door is open without invariant tension, unlike the frontmatter design. Deferred, default-off.

Index projection — v1 is FTS-only

Decided: highlights are FTS-searchable; no edges, no embeddings.

  • b2.sqlite gains a projected highlights table + FTS over (exact, annotation), rebuilt from the annotation store on reindex like every other projection. Reindex also caches each highlight's resolution state (anchored/orphaned — derived data, recomputed per pass) for the list surfaces; the open note in the desktop resolves live against the buffer.
  • b2 search results can carry a highlight origin so annotation text is findable; the list pane is a query over the projection.
  • Not in v1, named as future seams (M1 posture — deferred, default-off): a [[wikilink]] inside an annotation projecting as an edge; annotation text entering the embedding space / b2 similar.

Surfaces (v1)

Decided scope: desktop reading view + editor modes + highlights list pane + CLI.

  • Reading view — select text → highlight (context menu + chord), optional annotation editor; anchored highlights render tinted, annotation on hover/click.
  • Render modes — inline tint plus a notes side panel or footnote-style at the bottom of the reading view; a render preference (localStorage, beside the theme — a viewing choice, never vault state). Footnotes exist only in rendered HTML, never in the Markdown.
  • Editor modes — a CodeMirror decoration layer so highlights survive ⌘E (live preview and source). Presence only; editing near one simply changes how it resolves.
  • Highlights list pane — per-note quote + annotation + state, rows scroll to the anchor; orphans grouped with a re-pin affordance. Vault-wide listing rides the projection.
  • CLIb2 highlights <note> (--json) listing quote, annotation, and resolution state; delete by id. Creation from the CLI (naming a text range) is awkward — v1 CLI is read/delete; add --quote "…" [--nth N] is a stretch goal.
  • Keyboard (K1) — every chord lives in ui/src/bindings.ts and passes the four conflict checkers; the list pane follows the ARIA pattern over a nav module like the discovery pane; focus restore per the four obligations in crates/b2-desktop/CLAUDE.md.
  • Trust boundary (E5) — annotation text is untrusted Markdown: rendered only through renderMarkdown (sanitized), every interpolated value escaped.

Façade

Added on need (E3): Vault::highlights(note), add_highlight, update_highlight, delete_highlight, repin_highlight. Both adapters stay dumb: deserialize → one façade call → serialize, reusing the CLI's --json view types as the IPC contract.

Testing

  • Resolver — pure-function tests: exact hit; text moved wholesale; context edited; quote lightly edited (fuzzy pass); edited beyond threshold (orphan); and the same-text-twice family — both resolve to distinct occurrences, one deleted → the other still resolves, both survive edits between them.
  • Store — migration tests from v1 forward; CRUD determinism under FixedId/injected clocks; C1-style concurrent open.
  • Projection — drop b2.sqlite, rebuild → identical highlight rows; FTS finds annotation text. Fixture highlights are created programmatically in tempdir copies (a binary store doesn't belong in fixtures/golden-vault/).
  • Model-free throughout (E2).

Non-goals (v1)

  • No edges or embeddings from annotations (future seams, above).
  • No highlight colors — single style; the store can grow a nullable column by migration when wanted.
  • No highlights on resources (PDF annotation is its own future design; L3 untouched).
  • No visibility in other editors — accepted consequence of the DB home.
  • No sync/merge story beyond "the store travels with the vault".

Open questions

  • Final name/location of the store file (.b2/annotations.sqlite?) and the docs' backup guidance for vaults that gitignore .b2/.
  • Vault-wide highlights view: its own pane, or a search filter over the projection?
  • A Markdown export of a note's highlights (the "My Clippings" analog) beyond --json?
  • Colors: defer entirely (as above) or seed the nullable column now?

Suggested phasing

  1. Docs — land the invariant/data-model/index-engine amendments (this spec, made canonical).
  2. Core — annotation store (open/migrate), highlight CRUD through the façade, the anchor resolver + its test family.
  3. Projection + CLI — FTS projection, search integration, b2 highlights.
  4. Desktop — reading-view gesture, render modes, list pane, editor decorations, keyboard registry entries.

Sources

  • Spec session 2026-08-12: storage home = database (invariant amendment accepted); fuzzy + orphan + manual re-pin; v1 surfaces = reading view, editor modes, list pane, CLI; index participation = FTS only.
  • docs/design/invariants.md S1/S2/S4/S5, W1–W5, L1/L3, C1, E1/E2/E3/E5, K1; docs/design/data-model.md §0/§7 (body-pristine rationale, rejected alternatives); docs/design/index-engine.md §3/§8.
  • Prior art for the anchor model: W3C Web Annotation TextQuoteSelector; Hypothesis fuzzy anchoring.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions