How a note gets from disk into a view, what owns its state while it's open,
and how it gets back to disk. Complements EDITING_FLOW.md
(keystroke-level editor mechanics) β this doc is the note-level pipeline
around it.
Core invariant: the .md file is the source of truth. The SQLite index
(.steel/steel.db, SQLDelight) is a disposable mirror of frontmatter fields
used for lists and lookups. The KMP shared/ layer owns all file I/O,
parsing, serializing, and indexing; SwiftUI owns rendering, bridging through
one facade: SharedCoreService.
shared/src/commonMain/kotlin/com/steelnotes/model/
Four live note types (NoteType.kt):
| Type | Prefix | Folder | Concrete class |
|---|---|---|---|
NOTE | note | notes/ | Note β user engagement; resources: List<String> links to Resources, companionOf names the one it belongs to |
RESOURCE | src | resources/ | Resource β external material (book / article / bookmark / attached file) |
QUOTE | exc | quotes/ | Quote β one captured passage, FK resourceId β parent Resource |
AUTHOR | aut | authors/ | Author β a person whose works the vault collects; optional photo, url, free-form bio body |
Legacy thought/question/synthesis normalize to NOTE in
NoteType.fromString. Thoughts/questions today live as Marginalia entries
in Quote frontmatter.
A Resource's subtype is ResourceKind { BOOK, ARTICLE, BOOKMARK, ARTICLE_FILE }
(Statuses.kt:32), frontmatter key kind:, omitted for BOOK (byte
stability for existing files). Book-only fields: isbn, pages,
table_of_contents (nested TocEntry maps), thumbnail/thumbnail_source.
Article/bookmark fields: url, site_name, author.
ARTICLE_FILE (kind: article_file) is a resource whose content is an
attached document β file: attachments/<name>.pdf, one per resource β with the
user's own notes in the Resource body, like a book and unlike an article.
file: is registered with ReferenceParser (as an ATTACHMENT ref) and
ReferenceRewriter alongside photo/cover_image/photos, and with
countNotesReferencingAttachment (file_ref column): the body link is
stripped on conversion, so the frontmatter key is the document's only
reference and the attachment GC would otherwise reclaim it.
Article capture (share extension β AppContainer.importPendingCaptures,
AppContainer.swift:341) puts the **full extracted article markdown in
Resource.bodyText** (kind: article). The share sheet asks nothing: whether
a share is an article or a bookmark is decided from the extraction itself
(CaptureDecision β Readability markdown of 200+ characters is an article).
A bookmark's body is the user's own notes (2026-08-22), the way a book's
is; it starts empty, and its url/title live in frontmatter. It used to be
the line "Resource: <url>", with the notes in a companion β a launch-time
VaultManager.migrateBookmarkCompanions folds those companions back into the
bookmark body (and recovers a URL from the old line for bookmarks that were
captured without one).
The user's own writing about an article still goes in a companion Note
carrying companion: <src-id> and resource_url: β an article's body is the
clip, so it has nowhere else to go. See
COMPANION_NOTES.md. VaultManager.convertArticleToBookmark
throws the clip away and keeps the page as a bookmark, moving the companion's
writing into the body. Book quotes are separate Quote notes, never
sections of the book's body; the Resource's pages list is the union of pages
seen across its quotes.
The file format is YAML frontmatter + markdown body. Exactly two codepaths
touch it structurally: read = FrontmatterParser.parse β
NoteFactory.fromFrontmatter; write = NoteSerializer.buildMetadata β
FrontmatterParser.serialize. (A few maintenance paths in VaultManager β
legacy link migration, image-reference rewrites β patch the file text in place
instead, deliberately, to keep the diff minimal.)
The body has one canonical form, enforced in both directions so that a
round trip returns the same bytes it was given:
---, andexactly one trailing newline when the body is non-empty (an empty body ends
the file at the closing ---);
FrontmatterResult.body, item.bodyText) β no leading andno trailing newline.
This matters because sync defines "unchanged" as hash equality over the raw
file bytes (ChangeDetector, SyncDecisions.remoteAction), so a serializer
that can add or drop a trailing newline reads as an edit nobody made. Only
newlines are trimmed β trailing spaces on the last line are the user's text
and survive. The cost, accepted deliberately: blank lines at the very end of
a note do not persist across a reload.
Canonicalization reaches each note on its next natural save; nothing
mass-rewrites the vault, so there is no migration and no vault-wide re-sync.
1. List β VaultView.notesList (VaultView.swift:105) renders
viewModel.notes ([VaultItem] from the DB index). Each row is
NavigationLink(value: item.id) β the nav value is the id string.
2. Resolve β AppNavigation.swift:116:
.navigationDestination(for: String.self) calls
container.core?.getNote(id:) and constructs
NoteEditorView(existingNote:). The model is fully loaded before the
detail view exists. The same String destination serves wikilink taps and
deep links (steelnotes://note/<id>) via the navigateToNote env key.
3. Two load paths in VaultManager.kt:
getNote(id) (:715) β **DB-only** (dbNoteToModel :802; TOC/marginalia decoded from JSON columns, annotations from a join table).
Used for navigation, lists, transclusion.
getNoteFromFile(filePath) (:727) β reads the .md file. Everyread-modify-write re-reads the file first (readCurrentForEdit :481);
unparseable files are archived to .steel/conflicts/ and the DB model
used as fallback.
Caveat: Resource.pages has no DB column, so it only round-trips through the
file, not getNote.
**Threading and hydration rule: lists are lite and load off main; details are
full and load on main.**
listNotesLite, listNotesByFolderLite,listNotesByFolderTreeLite, listQuotesByResourceLite,
getResourcesByAuthorLite, getRecentContentLite share one private
implementation with their full twins and differ only in mapping through
dbNoteToModel(lite = true), which skips the per-resource annotation query
(an N+1 across the whole vault) and the marginalia decode. Nothing a list
row draws comes from either. The Swift wrappers on SharedCoreService
already route to these.
Resource and Quote carry an isLite marker that `dbNoteToModel(lite =
true) sets and copy() preserves, and updateNote/createNote require`
a hydrated item. Feeding a listed item into a whole-object write is a loud
IllegalArgumentException at the offending call site rather than the
silent loss it used to be: a quote's marginalia lives in frontmatter and
would be erased from the .md, and a resource's annotations (markup in the
body, re-parsed on read) would be dropped from the DB index until the next
reindex. The marker is @Transient β an item parsed from an .md file is
complete by construction.
BookCoverFetcher writesevery cover/ISBN/marker field through applyEdit, which re-reads the .md
and changes only the named fields, so it needs no whole Resource at all and
scans on listNotesLite. Marker writes pass bumpUpdated = false so a
lookup outcome doesn't reshuffle the vault's recency order. New background
writers should follow this shape rather than getNote β copy β
updateNote.
SharedCoreService.hydrate(_:).** Thatis the single refetch entry point β it no-ops on an already-full item, so a
detail route can call it unconditionally. QuoteDetailView uses it to get
marginalia; any new detail screen that renders marginalia or annotations
should do the same instead of writing its own getNote task.
VaultViewModel.refresh()debounces the post-sync reload, runs the KMP queries in a detached task and
assigns on the main actor; publishWidgetData reads four rows through
getRecentContentLite and encodes, both off main. Interactive reads (folder
taps, deletes, detail pushes) stay synchronous on main β they need the
result in the same frame. SharedCoreService.timed logs [MainKMP] for
any main-thread KMP call over 50ms in DEBUG.
NoteEditorView** (NoteEditorView.swift:46) dispatches by concretetype: Quote β QuoteDetailView; an existing Resource β
ResourceDetailView (the one-page resource view, see
RESOURCE_SEGMENTED_VIEW_PLAN.md); Note (and
a resource being created) β editorShell β NoteEditorContent. The shell
owns nav title, the ellipsis menu (frontmatter sheet, cover viewer,
delete), the AI-processing banner, and the TOC-capture full-screen cover;
ResourceDetailView owns the same chrome for resources.
NoteEditorContent** (NoteEditorContent.swift) owns editable state:NoteEditorSnapshot (8 string fields β title, bodyText, tagsText, author,
url, resourceId, resourceTitle, page) as @State fields + originalFields
baseline for dirty detection, plus loadedUpdatedAt (the optimistic-
concurrency stamp). It loads resource extras (resourceToc,
resourceQuotes via listQuotesByResource) and passes ~25 params into
UnifiedEditorView.
UnifiedEditorView / UnifiedEditorViewController /MarkdownEditorStorage** β the hidden-marker WYSIWYG editor
(see EDITING_FLOW.md). MarkdownEditorStorage.markdownSource is the
canonical in-editor text; the display string + OffsetMap are derived
from it. KMP MarkdownLineClassifier + InlineTokenizer +
FormattingEngine are the single grammar. For Resources, the controller
renders a resource header (cover, title, author, kind chip) and the
in-editor ResourceTocComponent (collapsible TOC / quote list; chapter
tap β ChapterQuotesView).
ResourceDetailView**, one long scrolling page: a bookreads header β notes β quotes, an article header β notes β the clipped
text, a bookmark header β notes, an article_file header β notes β the
document (ResourceFileSection: a PDF renders inline as a PDFPageColumn β
a plain column of page images at full document height, with no scroll view
of its own, so the page's single scroll runs from the notes into the pages;
pages render as they near the screen and release once past, and tapping one
opens the whole document full-screen at that page. Anything else is a
tappable QuickLook card). The page is this same NoteEditorContent
(with showsResourceSections: false, so the TOC isn't drawn twice) with the
extra sections passed as topAccessory / bottomAccessory and hosted
inside the editor's own scroll view. ResourceKind also drives header
differences
(Resource+Display.swift: fallback glyph, detail chip,
hidesBookOnlyFields).
ChapterQuotesView) uses a separatepath: MarkdownBlockParser.swift / InlineMarkdownParser.swift wrapping
KMP MarkdownDocumentParser β not the live-editor grammar.
All triggers live in NoteEditorContent:
1. Auto-save β .onChange(of: fields) β scheduleAutoSave() (2 s
debounce) β performSave().
2. Dismiss β .onDisappear β performSave(allowOverwrite: true) (no
alert can present from onDisappear; local edits win, sync archives the
other copy).
3. Explicit β retry / conflict-alert buttons.
performSave (NoteEditorContent.swift:347):
createNewItem generates <prefix>-<yyyy-MM-dd>-<8hex>,builds the concrete model, core.createNote. The id is remembered in
createdItemId so later auto-saves update instead of duplicating. New
in-app resources default to kind: .book + cover fetch.
core.applyEditGuarded(..., ifUpdatedAt: loadedUpdatedAt)β a partial edit with a staleness precondition (compared at millisecond
granularity, matching the DB's epoch-millis storage).
.saved refreshes loadedUpdatedAt; .stale first triesrebaseOntoDisk (field-level 3-way mergeFields; only a genuinely
contested field prompts the Keep-Mine/Use-Theirs alert); .failed alerts.
Shared-layer write path β VaultManager.applyEdit (:441) β
applyFields (partial merge; unset fields like TOC/marginalia round-trip
untouched from the parsed file) β bump updated β updateNote (:567):
1. NoteSerializer.serialize β markdown string
2. fileSystem.writeText β the file write
3. NoteIndexer.index in one SQLite transaction β row + links + annotations
4. referenceIndexer.indexFile β wikilink/transclusion graph
5. onFileChanged?.invoke(path) β sync signal, wired in
ServiceBundle.swift:82 to SyncManager.onLocalFileChanged, which
hash-checks (skip echoes of sync's own writes), marks PENDING_PUSH,
and debounces another 2 s before syncNow().
Note the two serial 2 s debounces (editor autosave, then sync push): a local
edit takes up to ~4 s to start pushing.
Remote changes while open: a sync pull bumps vaultGeneration β
refreshFromRemoteIfClean adopts the pulled version only if the editor is
clean and the ms-timestamp actually changed. Background writes (on-device
title generation, AI enrichment) are folded in via adoptBackgroundWrite
(field-merge; user's in-progress edit wins per field).
1. Note.resources: [src-id] β frontmatter resources:, DB column
resource_refs (comma-joined CSV).
2. Quote.resourceId β frontmatter resource_id, DB column resource_id;
getQuotesByResourceId (SteelNotes.sq:186) sorted by page_ref.
2b. Authorship. Resource.authorIds (frontmatter author_ids: [...], DB
column author_ids) names a work's authors; Quote.authorId
(frontmatter author_id) attributes a resource-less quotation to a
person. The two quote ties are mutually exclusive β when there is a
resource, attribution flows from it, and both NoteFactory and
NoteSerializer drop a stray author_id rather than let a file carry
two contradictory ties. The plain author: string stays on both as the
display snapshot: it is what search matches and what clients predating
the Author type read, so the string and the ids are kept in step rather
than one replacing the other. Queries: getResourcesByAuthor (a join on
the structured links rows, not a LIKE over the CSV β ids share
prefixes) and getQuotesByAuthorDirect.
VaultManager.backfillAuthors is the one-time, marker-gated pass that
turns legacy author: strings into linked Author notes; it matches by
normalized name, so a second device running it over synced files adopts
the existing notes instead of minting rivals.
3. Body references β [[wikilink]], , links/images,
extracted by ReferenceParser, indexed into links and
file_reference tables (backlinks via getBacklinks).
Every structured key above is indexed as a 'structured' row in links, so
backlinks cover them, and ReferenceRewriter.rewrite repoints them on a
rename or conversion (resource_id, companion, author_id as scalars;
resources, author_ids in both the inline [a, b] and block - a list
forms). Before that, converting a resource left every quote's resource_id
pointing at an id that no longer existed.
4. VaultManager.createResourceWithNote (:406) β creates a Resource plus a
companion Note with resources = [resource.id] (the documented
NOTE_ARCHITECTURE pattern; not all capture paths use it today).
A plain Note can become a Quote or a Resource from the editor's β¦ menu
(NoteEditorView β sheets in ConvertToQuoteSheet.swift /
ConvertToResourceSheet.swift). The mechanics live in KMP β
VaultManager.convertNoteToQuote / convertNoteToResource
(VaultManager.kt, tested by VaultManagerConvertNoteTest):
1. Mint a fresh id of the target type (mintId) and create the new file
(tags and created carry over; updated is now).
2. Rewrite every inbound steel:// reference to the new id
(ReferenceRewriter.rewrite over getBacklinks), re-indexing each
rewritten file.
3. Remove the original note β file, index, and reference rows, but **never
its attachments**: the quote may reference them only from context
frontmatter, which ReferenceParser cannot see, so deleteNote's orphan
GC would destroy user data.
NoteβQuote splits the body by paragraph (NoteConversionDraft, pure and
unit-tested): the tapped contiguous run becomes the quote (body), paragraphs
marked as the user's own comments become thought marginalia, and the
rest lands in context_before/context_after β lossless. The quote is
attributed to either a Resource or an Author (the segmented control in
the sheet's metadata bar); both offer a create-new path, so a quote is never
blocked on the work or the person not existing yet. A page number only
applies to the resource mode.
NoteβResource carries the body over and captures kind (book / article /
bookmark / file), title, and linked authors. The kind decides which fields
are real β ISBN and a cover picked from the note's attached images for a
book, a URL for article/bookmark, one of the note's non-image attachments for
a file β and ResourceDraft (pure, unit-tested) is what enforces that a field
typed before switching kind doesn't ride along into the saved resource. The
File segment appears only when the note actually has non-image
attachments (so NewResourceSheet, which has no note, keeps three); with
exactly one candidate it is preselected. Converting moves the chosen link out
of the body into file: via FileLinkStripper so the document doesn't
render twice β this is the only way an article_file resource is created; the
Evernote importer is untouched and PDF-only notes still land as plain
Notes.
With an ISBN and no hand-picked cover, the existing Google Books cover fetch
runs post-convert.
Authors in both sheets are always linked Author items, never a typed
string: AuthorPickerView creates one on the spot when the name is new, and
the author: display string is derived from the linked names at save time
(ResourceDraft.authorDisplayName, joined the way the Kotlin splitter reads
them back apart).
The editor flush-saves before the sheet opens (so the vault copy matches the
screen) and suppresses its dismiss-time autosave afterwards (convertedAway)
β the old id no longer exists to save into.