← Back to Plan

Note Lifecycle

Note Lifecycle β€” Load, View, Save

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.


1. The data model

shared/src/commonMain/kotlin/com/steelnotes/model/

Four live note types (NoteType.kt):

TypePrefixFolderConcrete class
NOTEnotenotes/Note β€” user engagement; resources: List<String> links to Resources, companionOf names the one it belongs to
RESOURCEsrcresources/Resource β€” external material (book / article / bookmark / attached file)
QUOTEexcquotes/Quote β€” one captured passage, FK resourceId β†’ parent Resource
AUTHORautauthors/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:

  • **on disk** β€” LF line endings, no blank line after the closing ---, and
  • exactly one trailing newline when the body is non-empty (an empty body ends

    the file at the closing ---);

  • **in memory** (FrontmatterResult.body, item.bodyText) β€” no leading and
  • no 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.

    2. Load: list β†’ detail

    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. Every
  • read-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.**

  • **Lite** β€” 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.

  • **A lite item cannot feed a read-modify-write β€” the code enforces it.**
  • 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.

  • **Background writers patch, they don't replace.** BookCoverFetcher writes
  • every 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.

  • **A detail view rehydrates through SharedCoreService.hydrate(_:).** That
  • is 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.

  • **Reads that fire on their own run off main.** 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.

    3. View: who owns what

  • **NoteEditorView** (NoteEditorView.swift:46) dispatches by concrete
  • type: 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).

  • A Resource opens in **ResourceDetailView**, one long scrolling page: a book
  • reads 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).

  • Read-only rendering (list previews, ChapterQuotesView) uses a separate
  • path: MarkdownBlockParser.swift / InlineMarkdownParser.swift wrapping

    KMP MarkdownDocumentParser β€” not the live-editor grammar.

    4. Save

    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):

  • Derives a title (user title β†’ first body line ≀40 chars β†’ "Untitled …").
  • **New item**: 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.

  • **Existing item**: core.applyEditGuarded(..., ifUpdatedAt: loadedUpdatedAt)
  • β€” a partial edit with a staleness precondition (compared at millisecond

    granularity, matching the DB's epoch-millis storage).

  • **Outcomes**: .saved refreshes loadedUpdatedAt; .stale first tries
  • rebaseOntoDisk (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).

    5. Linking mechanisms (between notes)

    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]], ![](steel://transclusion), 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).

    6. Type conversion ("Convert to…")

    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.