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.
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.
Surveyed: Readwise Reader, Matter, Omnivore, Kindle, Hypothesis, Zotero,
Evernote/Bear/Apple Notes clippers, Notion, Craft, Obsidian literature-note
workflows. Four patterns cover the field:
| Pattern | Examples | Note object | Filing | Standalone export |
|---|---|---|---|---|
| **A. Note as field of the document** | Reader ("Document Note"), Matter, Kindle, Hypothesis | Not an object β a property/annotation of the saved article | None; organizing the article organizes the note | Synthesized at export: template renders frontmatter + title + URL + highlights + note into one md file |
| **B. Child object under a parent** | Zotero child notes | Real object, structurally subordinate | Impossible β child notes cannot be placed in collections; they ride with the parent | Poor by default; every serious exporter (Better Notes, mdnotes) injects parent metadata into the file |
| **C. One merged mutable note** | Evernote/Bear/Apple clips, Notion pages | Clip and commentary share one body | One decision at clip time | Trivial, but article immutability is gone |
| **D. Literature note as primary** | Obsidian/Zettelkasten | User'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 note | Best 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.
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.
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).
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.
At companion creation, from the (immutable) Resource:
title: defaults to Notes β <resource title> (already listed as a knowngap 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.
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.
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.
and sync-merge arguments unchanged.
possible moment (capture), and still files the wrong object.
notes/resources/β¦).**Relocates the clutter instead of removing it, and burns a folder name.
correctly live in the Resource body (a book Resource has no external text
competing for it) β D1's call, still right.
| Today | After |
|---|---|
| Companion appears in root notes list as an unfiled peer | Invisible 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 line | Notes β <resource title> |
| File on disk names its resource by opaque id only | resource_url + resource-derived title; readable anywhere |
| Deleting a Resource strands the companion silently | Delete flow warns and deletes both |
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.
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 bothkeys 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.
(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):
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 rowstoday; only Resources use it). No second column.
companion_of = ? AND type = 'note';`
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:
companion: beats an *older* note that merely referencesthe resource (the synthesis-note misclassification from Β§1);
4. createResourceWithNote (VaultManager.kt:479-499):
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).
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.
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.
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.
./gradlew :shared:jvmTest (JDK 21) β serializer round-trips,CompanionNoteTest, CompanionPlacement.
SteelNotesTests scheme; then in the simulator against areal 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.
steel migrate backfill-companions --dry-run on a vaultfixture with a pre-existing heuristic-only companion.
xcodegen, not pbxproj edits.