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:
- Exact with context — find
prefix + exact + suffix. One hit → anchored.
- 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.
- Fuzzy — windowed approximate match (edit-distance similarity) seeded near
pos_hint, so the highlight follows lightly-edited text.
- 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.
- CLI —
b2 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
- Docs — land the invariant/data-model/index-engine amendments (this spec, made canonical).
- Core — annotation store (open/migrate), highlight CRUD through the façade, the anchor resolver + its test family.
- Projection + CLI — FTS projection, search integration,
b2 highlights.
- 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.
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:
==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.b2_highlights:(theb2_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 fromb2.sqlite. The line to hold is thatb2.sqlitestays 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 insideb2.sqlitewould silently break both. The annotation store is the opposite kind of artifact:.b2/annotations.sqlitetravels with the vault;b2 highlights --json(below) is the export escape hatch.Invariant amendments (docs are the source of truth — amend them first)
index = projection of (the vault directory, the annotation store). Dropb2.sqlite, reindex, get an identical index — still true.invariants.md,data-model.md(new section: the highlight object, and a "rejected alternatives" entry for the two Markdown homes), andindex-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 viaIdGen(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.exact— the highlighted text, verbatim;prefix/suffix— ~32 chars of body context either side;pos_hint— char offset ofexactat 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 differentprefix/suffix/pos_hintand always differentids; 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:prefix + exact + suffix. One hit → anchored.exact; score by context similarity and distance frompos_hint. Same-text highlights are resolved jointly, each occurrence consumed once, so two of them can never collapse onto one occurrence.pos_hint, so the highlight follows lightly-edited text.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.sqlitegains a projectedhighlightstable + 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 searchresults can carry a highlight origin so annotation text is findable; the list pane is a query over the projection.[[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.
localStorage, beside the theme — a viewing choice, never vault state). Footnotes exist only in rendered HTML, never in the Markdown.b2 highlights <note>(--json) listing quote, annotation, and resolution state;deleteby id. Creation from the CLI (naming a text range) is awkward — v1 CLI is read/delete;add --quote "…" [--nth N]is a stretch goal.ui/src/bindings.tsand 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 incrates/b2-desktop/CLAUDE.md.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--jsonview types as the IPC contract.Testing
FixedId/injected clocks; C1-style concurrent open.b2.sqlite, rebuild → identical highlight rows; FTS finds annotation text. Fixture highlights are created programmatically in tempdir copies (a binary store doesn't belong infixtures/golden-vault/).Non-goals (v1)
Open questions
.b2/annotations.sqlite?) and the docs' backup guidance for vaults that gitignore.b2/.--json?Suggested phasing
b2 highlights.Sources
docs/design/invariants.mdS1/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.TextQuoteSelector; Hypothesis fuzzy anchoring.