← Back to Plan

Companion Notes

Companion Notes β€” Where They Live

When an article is clipped, the full text lands in an immutable Resource

(resources/<id>.md) and the user's thoughts go in a companion Note. This

doc examines two problems with how that companion behaves today, surveys how

other tools solve the same problem, and recommends a direction.

Background: NOTE_LIFECYCLE.md (data model),

RESOURCE_SEGMENTED_VIEW_PLAN.md (D1, the

decision that created the lazy companion), NOTE_ARCHITECTURE.md.

Status: implemented (Β§1–§5 are the design; Β§6 is the plan it was built

from, kept for the reasoning behind each step). Phases 1–4 are done and

tested; the simulator pass in Β§6 "Verification" has not been run.


1. The problems

Today the companion is created lazily on first keystroke in the resource's

notes section (ResourceDetailView.swift), as a regular Note at the vault

root notes/ with resources: [<src-id>] frontmatter. Two things are wrong

with the result:

P1 β€” It presents as a sibling, not a child. The companion appears in the

root notes list next to real notes, looking like one more thing to file.

Conceptually the user never created a note β€” they wrote on an article. The

article is the thing they'd organize; the companion should ride along.

Asking them to also categorize the companion is a filing decision the model

invented, not one the user's mental model contains.

P2 β€” The file doesn't stand alone. The .md is the source of truth, but

a companion on disk reads as:

```yaml


type: note

id: note-2026-07-22-cce1abf7

title: <auto-generated from first line>

resources: [share-2026-07-20-154839]


Interesting point about sleep debt...

```

Outside the app β€” grep, a git diff, another editor, a future export β€” that

file is commentary on an opaque id. Nothing says what article, by whom, or

where it came from.

A third, structural wart feeds both: companionship is a heuristic, not a

fact. findCompanionNote picks the oldest non-archived Note whose resources

contains the id (VaultManager.kt:424). A synthesis note that happens to

reference the resource, written before any companion existed, silently

becomes the companion. CompanionNoteTest.kt exists to pin the edge cases

(prefix trap, archived skip) that only exist because identity is inferred.

2. How everyone else does this

Surveyed: Readwise Reader, Matter, Omnivore, Kindle, Hypothesis, Zotero,

Evernote/Bear/Apple Notes clippers, Notion, Craft, Obsidian literature-note

workflows. Four patterns cover the field:

PatternExamplesNote objectFilingStandalone export
**A. Note as field of the document**Reader ("Document Note"), Matter, Kindle, HypothesisNot an object β€” a property/annotation of the saved articleNone; organizing the article organizes the noteSynthesized at export: template renders frontmatter + title + URL + highlights + note into one md file
**B. Child object under a parent**Zotero child notesReal object, structurally subordinateImpossible β€” child notes cannot be placed in collections; they ride with the parentPoor by default; every serious exporter (Better Notes, mdnotes) injects parent metadata into the file
**C. One merged mutable note**Evernote/Bear/Apple clips, Notion pagesClip and commentary share one bodyOne decision at clip timeTrivial, but article immutability is gone
**D. Literature note as primary**Obsidian/ZettelkastenUser's note is first-class; the *resource* is represented inside it (frontmatter URL/author + embedded quotes)One object to file, and it's the user's own noteBest by construction β€” the note is written to stand alone

Pattern C is what D1 already rejected (mixing article text with user edits),

and it stays rejected. The interesting finding is what nobody ships: **two

sibling notes the user files independently** β€” which is what our current

presentation drifts toward. The consensus for saved articles is Pattern A's

behavior (zero filing, note travels with the document) combined with

Pattern D's file format (the user-authored artifact carries enough resource

context to stand alone).

Zotero is the cautionary tale for P2 specifically: pure child objects export

badly, and their entire plugin ecosystem exists to stamp parent metadata

back into the exported file. Better to stamp it at birth.

3. Recommendation

Keep the companion as a first-class Note. D1's reasoning still holds β€” it

must be searchable, linkable, and transcludable, and the Resource must stay

immutable. What changes is that the companion becomes an explicit child:

it knows what it belongs to, it lives with its resource, and it never asks to

be filed.

C1. Make companionship a fact, not a heuristic

New frontmatter key on Note: companion: <src-id>, written by every path

that deliberately creates a companion (lazy create in ResourceDetailView,

createResourceWithNote, the Evernote importer, CLI backfill). Resolution

becomes a lookup, not oldest-wins; findCompanionNote keeps the heuristic

only as a legacy fallback until steel migrate backfills the key onto

currently-resolved companions.

This reverses D1's "no new frontmatter key" line, deliberately. That line

was right to refuse a new type and a new storage location; it was wrong

to leave identity inferred. The prefix-trap and oldest-wins tests are the

cost of inference, and C2 below is impossible without a reliable membership

test (hiding the wrong note from the notes list is worse than the current

clutter).

C2. Derived placement β€” the companion rides with its resource

A companion (companion: set, no folder assigned) does not appear in folder

browsing at all. It is reached through its Resource's notes section, through

search, and through links β€” all of which it keeps as a first-class Note.

There is nothing to file because, from the user's side, there is no loose

object: notes on the article live with the article, the Zotero/Reader

behavior.

Chrome: the companion's notes section draws no title field β€” just the

writing surface. It is titled after its resource, so the field would only ask

the user to maintain metadata for a note they never chose to create. (A book's notes section edits the Resource,

so it keeps the resource header: cover, author, title.)

No escape hatch (2026-08-22). Filing a companion into a folder used to

"promote" it into the notes list. That is gone, along with

VaultManager.promoteCompanion: a companion is hidden wherever its file

sits. The user never asked for a companion and has no name for one β€” as far

as they can tell, their notes live on the resource β€” so a second place the same

writing could show up reads as a duplicate, not an escape hatch.

Deleting a Resource deletes its companion with it, behind a destructive warning

that says so ("This will move the resource and the notes you wrote on it to

trash"). The old "Keep my notes" button is gone for the same reason: it named

an object the user does not know exists.

The cascade is VaultManager.deleteNote's (2026-08-23), not a view's. It

shipped in ResourceDetailView alone, so the swipe-delete in the Resources list

took the Resource and left the companion behind β€” invisible in every folder

listing, with no page left to reach it through. deleteNote now resolves

findCompanionNote for any RESOURCE row and deletes the companion first (so

an attachment the Resource still shows survives the companion's orphan sweep

and is collected by the Resource's), and every delete path inherits it. The

views own only the warning: the list swipe now shows the same destructive

dialog when the Resource has a companion. Companions already orphaned by a

pre-fix list delete are not swept β€” that is a separate migration decision.

C3. Stamp resource context at creation β€” the file stands alone

At companion creation, from the (immutable) Resource:

  • title: defaults to Notes β€” <resource title> (already listed as a known
  • gap in RESOURCE_SEGMENTED_VIEW_PLAN).

  • resource_url: stamped once from Resource.url.
  • That's the whole fix for P2. The raw file now reads as *notes on a named,

    reachable article* anywhere markdown renders. No stale-data risk: article

    Resources are immutable, and the stamp is write-once. Body stays untouched β€”

    seeding boilerplate into the user's writing surface is Pattern C creeping

    back in.

    NoteSerializer/NoteFactory gain the two keys plus companion:; the

    "unknown keys are destroyed on first edit" invariant

    (EVERNOTE_IMPORT.md) is exactly why these must become

    known keys, not importer inventions.

    C4. Later: export assembles the literature note

    The Kindle/Reader move: a per-resource digest generated on demand β€” resource

    metadata, quotes with their marginalia, then the companion body. That is

    an export/share feature, not a storage change; C1–C3 make it a pure

    assembly job. Not scoped here.

    4. Rejected

  • **Demote the companion to a Resource field / sidebar notebook (pure
  • Pattern A).** Loses first-class search, links, and transclusion β€” the

    stated point of D1. Reader-style fields work for apps whose unit is the

    document; ours is the note.

  • **Append notes to the Resource body.** Already rejected in D1; immutability
  • and sync-merge arguments unchanged.

  • **Folder picker at clip time.** Moves the filing burden to the worst
  • possible moment (capture), and still files the wrong object.

  • **Auto-file companions into a dedicated folder (notes/resources/…).**
  • Relocates the clutter instead of removing it, and burns a folder name.

  • **Migrate book notes onto companions for uniformity.** Books' notes
  • correctly live in the Resource body (a book Resource has no external text

    competing for it) β€” D1's call, still right.

    5. Consequences for current behavior

    TodayAfter
    Companion appears in root notes list as an unfiled peerInvisible in folder browse wherever its file sits; lives in the Resource's notes section
    Companion = oldest note referencing the resource (heuristic)Companion = note with companion: <src-id> (fact); heuristic kept as legacy fallback
    Title auto-generated from first lineNotes β€” <resource title>
    File on disk names its resource by opaque id onlyresource_url + resource-derived title; readable anywhere
    Deleting a Resource strands the companion silentlyDelete flow warns and deletes both

    6. Implementation plan

    Ordered phases; each KMP phase is test-first (write the failing jvmTest/

    commonTest, then the code). Paths and line refs are from dev @ 089dc7d.

    As built, the plan held with three deviations, all noted inline below:

    resource_url reuses the existing url column rather than adding one; the

    placement rule became CompanionPlacement in model/ (matching

    ResourceViewPlanner) rather than a property on Note; and promotion needed

    a KMP VaultManager.promoteCompanion rather than falling out of moveNote,

    because the delete flow has to drop the dangling reference too.

    Global guardrail β€” byte stability. All new frontmatter keys are emitted

    only when set (mirror the kind-omitted-for-BOOK pattern in

    NoteSerializer.buildMetadata). Existing notes must not churn through

    reserialize on the next reindex.

    Phase 1 β€” model + persistence (KMP)

    1. Note gains two fields (shared/.../model/Note.kt):

    companionOf: String? = null (frontmatter companion) and

    resourceUrl: String? = null (frontmatter resource_url).

  • NoteSerializer.buildMetadata (NoteSerializer.kt:24-29): emit both
  • keys in the is Note branch, only when non-null. Put companion

    right after resources so raw files group the linkage.

  • NoteFactory.fromFrontmatter (NoteFactory.kt, resources parse is
  • ~:31): parse both keys.

  • Tests first: round-trip a Note with and without the new fields
  • (without = serialized output byte-identical to today); parse a file

    carrying the keys; re-serialize preserves them (this is the

    "known keys survive edit" proof β€” cf. EVERNOTE_IMPORT.md's

    destroyed-on-edit invariant).

    2. DB index (shared/.../db/SteelNotes.sq,

    SteelDatabaseMigrations.kt):

  • New column companion_of TEXT on notes: add to the CREATE TABLE,
  • to insert/update statements, and an addCompanionColumn probe-guarded

    ALTER TABLE in SteelDatabaseMigrations.migrateIfNeeded (copy the

    addResourceRefsColumn shape).

  • Note.resourceUrl reuses the existing url column (NULL for Note rows
  • today; only Resources use it). No second column.

  • New query `getCompanionOfResource: SELECT * FROM notes WHERE
  • companion_of = ? AND type = 'note';`

  • Wire both fields through VaultManager's row↔model mapping
  • (dbNoteToModel and the insert path) so a DB-loaded Note carries

    them β€” otherwise an edit through the fast path would drop the keys

    from the file on reserialize.

    3. findCompanionNote becomes lookup-first

    (VaultManager.kt:424-432): try getCompanionOfResource (skip

    ARCHIVED); only when empty, fall back to the existing oldest-wins

    heuristic (unmigrated vaults). Tests first, in

    shared/src/jvmTest/.../CompanionNoteTest.kt:

  • explicit companion: beats an *older* note that merely references
  • the resource (the synthesis-note misclassification from Β§1);

  • fallback still resolves when no note carries the key;
  • existing edge-case tests (prefix trap, archived skip) stay green.
  • 4. createResourceWithNote (VaultManager.kt:479-499):

  • stamp companionOf = resource.id, resourceUrl = resource.url;
  • title = noteTitle ?: "Notes β€” ${resource.title}";
  • filePath = "notes/$noteId.md" β€” replacing the $slug.md path,
  • which violates the id-==-filename-stem invariant

    (EVERNOTE_IMPORT.md Β§"Note id == filename stem"). Delete the slug

    code. Update the CLI backfill in cli/.../Main.kt:785-855 the same

    way (it clones this shape).

    Phase 2 β€” vault migration

    As built, this runs automatically. VaultManager.migrateCompanionLinks

    is called from SharedCoreService.bootstrap after the launch reindex (it

    reads the DB index, so it cannot live in initializeVault with the other

    migrations), gated by a .steel/companion-migration-v1 marker exactly like

    migrateLegacyLinks. Existing vaults convert on next launch with no command

    to run. The CLI subcommand below still ships for --dry-run inspection and

    for re-running after the marker exists.

    5. steel migrate backfill-companions (new subcommand beside

    backfill-resources, cli/.../Main.kt:785): for each Resource, resolve

    the companion via the legacy heuristic; if it lacks companion:,

    write companionOf (+ resourceUrl from the Resource when missing)

    through the normal edit path. --dry-run prints the pairs. Do not

    retitle existing notes β€” user-visible titles are not ours to rewrite

    in a migration.

    Phase 3 β€” iOS creation and placement

    6. Creation stamps β€” NoteEditorItemFactory.make

    (NoteEditorItemFactory.swift:16) gains companionOf: String? = nil

    and resourceUrl: String? = nil, passed into the Note(...) init.

    ResourceDetailView.swift:151-163 / ResourceDetailModel.swift:73-81

    supply resource.id, resource.url, and seed the title

    Notes β€” <resource title>. Find where auto-title generation runs in the

    new-note save path (NoteEditorContent.swift) and make sure a

    pre-seeded title is not overwritten. Tests:

    NoteEditorItemFactoryTests.swift, ResourceDetailModelTests.swift.

    7. Folder-browse exclusion. Add a pure KMP predicate (commonTest'd,

    like ResourceViewPlanner):

    `CompanionPlacement.isHiddenFromNotesList(note) = note.companionOf !=

    null (the original && folder is empty` clause was removed 2026-08-22

    with promotion β€” see Β§C2). Apply it in VaultViewModel.swift:33-39

    alongside the existing Resource/Quote filter. Search, links,

    transclusion, and the Resource notes section are untouched β€” the note is

    hidden from folder browsing only.

    8. ~~Promotion~~ β€” reversed 2026-08-22. The folder clause came out

    of the predicate: isHiddenFromNotesList(note) = note.companionOf != null,

    full stop, and promoteCompanion is deleted. See Β§C2.

    9. Resource delete flow (ResourceDetailView ellipsis menu) β€” **simplified

    2026-08-22** to a two-way Delete / Cancel whose warning names the notes

    going with it. The original three-way choice ("Keep note") shipped first

    and was removed: it asked the user about an object they have no name for.

    Moved into KMP 2026-08-23 β€” see Β§C2. The cascade is

    VaultManager.deleteNote's, so the Resources-list swipe-delete cascades and

    warns too; the views keep only the dialog copy.

    Phase 4 β€” importer + docs

    10. The Evernote importer (tools/, spec in EVERNOTE_IMPORT.md) stamps

    companion + resource_url on the companion notes it creates for web

    clips; update that doc's frontmatter-key table (the keys are now

    known, not invented).

    11. Doc sweep: NOTE_LIFECYCLE.md key list; RESOURCE_SEGMENTED_VIEW_PLAN.md

    D1 gets a pointer here; flip this doc's status line to implemented.

    Verification

  • KMP: ./gradlew :shared:jvmTest (JDK 21) β€” serializer round-trips,
  • CompanionNoteTest, CompanionPlacement.

  • iOS: run the SteelNotesTests scheme; then in the simulator against a
  • real vault: clip an article β†’ type in notes section β†’ confirm the file at

    notes/note-<date>-<hex>.md carries companion:, resource_url:, the

    Notes β€” … title, and does not appear in the root notes list;

    move it to a folder β†’ it appears; delete the Resource β†’ dialog offers

    keep/delete.

  • Migration: steel migrate backfill-companions --dry-run on a vault
  • fixture with a pre-existing heuristic-only companion.

  • New Swift files (if any) go through xcodegen, not pbxproj edits.