This document defines why the Rust project exists, what it must enable, and
the constraints that should guide implementation decisions. TODOS.md is the
execution backlog; every backlog item must reference an intent ID from this
document.
The project is a small desktop workspace for notes that link to each other: notes, an editor, and chains over one persisted record. Heavier tools — a TOC manager, a PDF reader, an image viewer, a map explorer — were considered and are deliberately out of scope for this iteration; see I4.4.
- Keep the intent IDs stable. Add a new intent instead of silently changing the meaning of an existing one.
- When proposing work, identify the highest-level intent it serves.
- A TODO is valid only when it has an
Intent:reference and a verifiable outcome. - If a task conflicts with a higher-level intent, resolve the conflict here before implementing it.
Provide a small eframe/egui application in which the correctness-critical logic — the note model, the link graph, the atomic store, the persistence merge — lives in widget-free modules with tests, while the views stay thin and a contributor can understand the data flow from one composition root.
Success looks like: a contributor can run cargo build, cargo test,
and cargo clippy on a clean checkout, open src/main.rs, and follow a
keystroke from a view to the shared state to the debounced write without
crossing a layering rule to do it.
Notes, Editor, and Chains are not isolated panes. One record owns the note list, the selected note, each note's body, and the cross-note links (a note pointing at other notes). The chains panel reads outgoing links and backlinks from that same record; the editor mutates it; the sidebar lists it.
Success looks like: a contributor can point at one store that owns the shared state, and a user moves from a note to a linked note and back without re-declaring or re-losing anything.
Anything that decides whether a link is valid, how state is clamped, what survives a restart, or how an id maps to a note belongs in a non-UI module, not in an egui widget closure. A view that parses a file itself, invents a serialization format, or reaches past the store for shared state has crossed this boundary without saying so.
The workspace is written through one path: a debounce, a synchronous write-to-temp then rename, a size cap checked before reading, and a merge on boot in which the durable copy wins unless it is strictly older than the in-memory snapshot. A pending debounce is flushed — not dropped — when the app exits. A successful boot restore sets the save mode so the status bar reads truthfully.
Power-loss limit, stated not implied: the file is flushed but NOT fsynced before the rename, and the parent directory is NOT fsynced after it. A crash-during-write is safe (old or new file, never half-written); power loss in the rename window may still lose the last write.
Success looks like: type a sentence, close the window, reopen, and the sentence is there; corrupt the file and the app still starts with a readable explanation instead of a panic.
A link is a directed edge between two note ids, stored on the source note. Backlinks are derived, never stored twice. Deleting a note removes its outgoing edges and every incoming edge that points at it. The chains panel shows both directions. A visual graph view is a later intent (I4.4 defers it), not a requirement of the first iteration.
Success looks like: link A→B, see B in A's outgoing list and A in B's backlinks; delete B and A's outgoing list no longer dangles.
Favor clear code paths, a short dependency list, reproducible commands, and documentation that matches the code that actually runs.
Each note's existing creation and update timestamps should support a compact monthly calendar view. The calendar is derived from the note record, uses UTC for deterministic date grouping, and lets a user move from a day to the notes that were created or updated on it without introducing a second metadata store.
Success looks like: a user can browse months, see which days contain note activity, click a day, and jump to one of that day's notes.
The active note should have an explicit read-only view and an edit mode, and the user should be able to export that active note, including its metadata, as a portable PDF through a native file picker.
Success looks like: view mode cannot change note text, edit mode can, and the active note can be saved as a readable PDF without changing workspace state.
The selected note, the edited body, and the scroll position survive a note switch; the record survives a restart.
A missing title, an unreadable or corrupt workspace file, or a link that no longer resolves must produce a clear, actionable sentence in the status bar rather than silence or a panic.
The status bar, the note count, the outgoing/backlink lists, and the save mode must reflect what actually happened. A label that claims work was not saved when it was, or shows a link that no longer resolves, is a defect even if nothing crashes.
The README, the clippy lints, the test suite, and the build commands should agree on the supported workflow.
The app state owns what reaches disk (the notes, the selection, the next id); the UI holds only transient state (the search box text, the open popup). A piece of state that both need must have one owner and one write path.
The store returns a Result instead of panicking, a corrupt file degrades
to an empty workspace with a reported reason, and no failure path leaves a
half-written workspace file.
The data directory, the supported desktop targets, and the toolchain assumptions are stated in the README and, where the platform requires it, in code — rather than assumed at runtime.
WorkspaceStore (or a Storage trait) is the only place that touches the
filesystem for the workspace, so it can be swapped, faked, or bounded in one
edit.
The note model, the link graph, the atomic store, the merge rules, and the boot composition have proportionate automated verification.
The rustc/edition version, the platforms that are actually built, and the dependency list should be documented and checked.
The storage backend, the view modules, and the app state should be changeable independently behind small contracts.
PDF reading, image viewing, and map exploration are out of scope for this iteration; so is WebAssembly compilation. Building any of them here is a product decision, not an oversight, and would need new intents before new code. Recorded as decisions, not omissions — see TODOS TODO-013 and TODO-014.
Verified against the tree rather than against these entries, on 2026-10-16
with rustc 1.98.1 and eframe/egui 0.36.2 — cargo build clean,
cargo clippy --all-targets clean, cargo test 58/58 passing. Every claim
below was checked by running it on that date.
Already present and working:
- A runnable eframe window (1000×700, min 640×480) with a top menu bar (File: New note / Undo delete / Quit; Help: About), a bottom status bar with a save-mode label, a left note list with live search, a central title+body editor, and a right chains panel with a monthly metadata calendar — I1.1.
- Persistence through one seam:
WorkspaceStore(src/store.rs) owns the only filesystem path (dirs::data_dir()/chainnotes/workspace.json), checks a 4 MiB cap before reading, writes atomically (temp + rename), and applies the boot merge (store::merge: durable wins unless strictly older than the in-memory snapshot).AutoSaver(src/autosave.rs) debounces at 250 ms andon_exitflushes a pending write instead of dropping it. A first run starts the demo marked dirty, so the boot debounce writes the file instead of leaving the status bar claiming "Saved to disk" for a file that does not exist — I1.3, I3.4, I2.1, I2.3. - Bounded failures:
StoreError(TooLarge / Corrupt / Io / WriteFailed / RenameFailed) maps every failure to a distinct sentence viamessages.rs; a corrupt or oversized file degrades to a readable status line, never a panic; no user-data path callsunwrap— I3.2, I2.2. - The widget-free core:
state.rs(theChainNotesrecord),graph.rs(link-graph queries and invariants),history.rs(the undo slot),autosave.rs(debounce/flush),store.rs,command.rs,messages.rs,note.rs,calendar.rs(UTC date helpers) — I1.2. - One write path: every record mutation enters through
ChainNotesApp::handle(Command)(src/app.rs); panels and menus emitCommands (src/command.rs) rather than mutating state directly, so a future shortcut or toolbar reuses every mutation — I1.2, I4.3. - Undo delete: a one-deep slot (
src/history.rs) restores the deleted note and every incoming edge at its original position — I2.1. - The
Notemodel: id, title, body, directedlinks,created_at/updated_at,touch()on edit, relative-time formatting — I1.4. - 58 tests across ten modules (note 4, calendar 3, graph 5, history 3, state 13, store 12, autosave 6, app 8, messages 2, export 1): id allocation, delete stripping both edge directions, undo + edge-position restore, search, record round-trip, corrupt/empty/over-cap loads, atomic-write failure leaving the previous file intact, error-sentence distinctness, the merge tiebreak, the boot wiring that restores a previously saved record, the save-mode truthfulness of first-run / loaded / corrupt boots, the debounce/flush controller, and the command write path — I4.1, I2.3.
- Sixteen source files (eleven in
src/plus the five insrc/ui/), every one under 300 production lines — I0.1.
Known gaps, in the order they should be closed (the full backlog with
definitions of done lives in TODOS.md):
- No graph view. Chains are lists, not a visual graph — I1.4 (deferred by I4.4). TODO-003.
- No import/export. A workspace cannot be moved between machines by file yet — I1.1, I2.2. TODO-004.
- Keyboard shortcuts now cover new/delete/save/search through one egui shortcut table; manual cross-pane verification remains part of TODO-007.
- CI and README refresh. The workflow, toolchain pin, and docs pass are
present; TODO-009 remains open until a green run is recorded on
main— I4.1, I1.5.
Every tool follows one pipeline: a gesture in a view, a Command through
the single write path (handle), a decision in the app state, a write
through the single record, and a visible confirmation. Nothing reaches
disk except through the store, and nothing reaches the filesystem except
through that one seam.
| Tool | Input (gesture) | Process (where the decision lives) | Output (record + visible feedback) |
|---|---|---|---|
| Notes | "+ New" button; Delete button; search keystroke; list-row click | Panels emit Command::NewNote / DeleteSelected / SelectNote; handle() calls ChainNotes::add_note / delete_note (which allocate the id, strip dangling links via graph, set the selection and the status sentence); visible_notes filters by case-insensitive title+body substring |
notes vec + selected + status line; sidebar rows and count |
| Editor | View/Edit toggle; staged title/body TextEdit change; "Link…" popup click |
View is read-only; Edit emits Command::NoteEdited (stamps updated_at); the popup emits Command::ToggleLink to add or remove an edge |
notes[i].title/body/links; status line "Edited note #N" |
| Chains | Outgoing/backlink row click | Emits Command::SelectNote with the target id |
selected; the editor switches to that note |
Global flows:
boot: main() → NativeOptions (viewport, title) → run_native
→ ChainNotesApp::new → WorkspaceStore::load
Ok(Some) → merge(boot_default, durable) → "Loaded N notes."
Ok(None) → demo record → "New workspace — no file yet."
marked dirty → first debounce writes the file
Err(e) → demo record → e.sentence() (corrupt/over-cap/unreadable)
not dirty → the unreadable file is left on disk
output: save mode = Saved (loaded file), Dirty (first run, until
the boot flush lands), or None (load error: disk does not
match memory)
keystroke: view → TextEdit::changed() → Command::NoteEdited
→ handle() → state.touch_selected() → saver.touch()
→ 250 ms debounce → saver.flush()
→ store.save (temp + rename) → SaveMode::Saved | Failed
output: status sentence kept; save-mode label in the status bar
shutdown: on_exit → saver.flush() (a pending write is flushed, not
dropped); Quit menu runs handle(Command::Quit) which also flushes
before ViewportCommand::Close
output: the last edit survives a window close
One thing this snapshot exists to prevent. A "current state" section
once claimed a restart restored work before the wiring that made it true
existed — the claim was written from the intent's point of view, not the
code's. This snapshot holds the line: it claims persistence only because
WorkspaceStore exists, the boot path calls it, on_exit flushes it, and
tests lock the round-trip, the merge rule, and the boot wiring itself
(TODO-001 and TODO-002 are closed with that evidence in TODOS.md). It
does not claim a graph view, import/export, or a CI run,
because none of those exist. A document describing intent can be
aspirational; a document labelled current state cannot, and it is the
label that makes people stop checking.