# NotesCraft architecture Last updated: 2026-10-19 (editor previews; code blocks follow-up: embedded TOML and TypeScript grammars, language labels) · branch `develop` · see `SPEC.md` for the contract and `ROADMAP.md` for status. ## Shape Rust workspace of one crate per concern (`crates/`, package `notescraft-`), layered so a crate may depend only on strictly lower layers (`cargo xtask layers`). Pure data and format crates sit at the bottom; the engine (command registry and `Session`) sits in the middle; egui lives only in L5 crates. Every user-visible action will be an engine command shared by the UI, `notescraft-cli`, the control channel, MCP and plugins (SPEC 10). ``` L0 vault markdown syntax L1 index canvas diagrams whiteboard L2 backlinks graph templates L3 plugins themes calendar L4 engine L5 editor mcp i18n L6 ui-egui (egui only in editor and ui-egui, and in apps) testkit (test helpers) apps/notescraft apps/notescraft-cli xtask ``` > [!note] > Crate status and public API Status: **done** (implemented, tested, clippy-clean, merged), **partial**, **stub** (placeholder with one `TODO(phase N)`). | Crate | Layer | Status | Src / test LOC | Tests | |---|---|---|---|---| | `vault` | L0 | done | 1016 / 587 | 42 | | `markdown` | L0 | done | 2051 / 222 | 51 | | `math` | L0 | done | 2157 / 699 | 54 | | `syntax` | L0 | done: fenced-code highlighting, language suggestions, grammar fetch/validate, embedded TOML and TypeScript grammars | 1146 / 393 | 39 | | `index` | L1 | done (links, tags, BM25 search) | 2887 | 108 | | `backlinks` | L2 | done | 686 | 14 | | `canvas` | L1 | done | 1193 / 379 | 31 | | `diagrams` | L1 | done: flowchart, sequence, class, state, ER, gantt, pie | 5226 | 102 | | `whiteboard` | L1 | done | 1789 / 551 | 60 | | `graph` | L2 | done | 915 / 302 | 33 | | `templates` | L2 | done | 1102 / 287 | 40 | | `plugins` | L3 | done | 1558 / (integration tests) | 58 | | `calendar` | L3 | done: events, recurrence, quick add, queries, reminders, ICS and Google feeds | ~3900 / ~2300 | 110+ | | `themes` | L3 | done | 1539 / 319 | 15 | | `engine` | L4 | done for v1 scope: registry, `Session`, buffers, search, daily notes, themes, settings, plugin host bridge, attachments, slash menu, batch review | 16182 / 8442 | 443 | | `i18n` | L5 | done | ~900 | 47 | | `ui-egui` | L6 | partial: shell, editing, settings, canvas and whiteboard widgets (graph and diagram views pending) | 51447 / 6918 | 962 | | `editor` | L5 | partial: highlighter, key behaviours, completion, preview scanners for colours and trees (wired into the app) | 2806 / 952 | 112 | | `mcp` | L5 | done | 2093 | 74 | | `acp` | L0 | done | 1954 / 480 | 42 | ### `notescraft-vault` (L0) Vault root, lock, path-traversal guard, atomic IO, watcher. `Vault::open(root)` / `open_read_only`, `resolve(rel)` (rejects `..`, absolute, drive/UNC, NUL, escaping symlinks, with `Error::OutsideVault`), `read`, `write` (via `craft-atomic`, returns `WriteOutcome::{Written, Unchanged}`), `rename`, `delete_to_trash` (into `.notescraft/trash/`), `create_dir`, `list` (sorted, hidden dirs skipped, depth 64), `watch()` (notify, 250 ms debounce, self-write suppression, `VaultEvent`), `write_private` for `.notescraft/`. `.obsidian/` and `.notescraft/` are `Error::Reserved` for normal writes. Lock `.notescraft/lock` with stale-pid takeover (`/proc`, `kill -0`, `tasklist`). Read cap `MAX_READ_BYTES` = 64 MiB. ### `notescraft-markdown` (L0) Lossless Obsidian-flavoured parsing. `parse(&str) -> Document { source, frontmatter, items }`; `Document::write()` returns the source unchanged; edits go through `replace_range(Range, &str) -> Result` (never clamps; `Error::InvalidRange` / `NotCharBoundary`) and `text(range)`. Items: `Heading`, `WikiLink`, `Embed`, `Link`, `Tag`, `BlockId`, `Highlight`, `Comment`, `Callout`, `Math`, `TaskItem`, with iterators `wikilinks()`, `headings()`, `tags()`, `embeds()`, `callouts()`, `block_ids()`, `all_tags()`. `CalloutKind` covers the 13 types plus aliases and `Unknown`. Frontmatter is a minimal YAML subset with `get`/`keys`, `edit_key` / `remove_key` that change only that key's lines, and the typed properties layer `frontmatter_edit` (SPEC 6 item 20): `set_key(src, key, &YamlValueText)`, `remove_key`, `rename_key` and `value_raw(src, key)` rewrite exactly one entry (append missing keys, create the block at byte 0 when absent, keep comments/quoting/CRLF byte-exact, edit and remove duplicate keys at the first occurrence, cap single-line entries at 1 MiB), and `PropValue` (`Text`/`Number`/`Bool`/`Date`/`DateTime`/`List`) with `render`/`infer`/`infer_list`, `PropType` with `infer_type` and `parse_types_json` for `.obsidian/types.json`. Helpers: `parse_link_target`, `anchor`, `anchors_match`, `decode_percent`, `is_internal_dest`. Nesting depth capped at 64. ### `notescraft-vim` (L0) Pure modal-editing state machine (SPEC item 29), no egui and no IO. `Vim::handle(Key, &mut Doc) -> Outcome` runs Normal, Insert, Visual, VisualLine, Replace and CommandLine modes over a concrete `Doc { text, cursor, anchor }`; counts and pending keys cap at 100,000 / 4,096 keys, documents at 8 MiB, registers at 32 MiB, and every cursor update is UTF-8-bounded (`get`/`prev`/`next`, never indexing). Motions (`h j k l w b e W B E 0 ^ $ gg G { } % f F t T ; ,` plus viewport-relative `H M L` `Ctrl-d/u`, arrow keys, Home/End/PageUp/PageDown), operators (`d c y > < g~ gu gU` with doubled forms and text objects `iw aw iW aW ip ap i( i) i[ i{ i" i'` plus backtick forms, including `a` variants), single keys (`x X s S C D Y r R J ~ o O A I a i p P u Ctrl-r .` dot-repeat), registers (`"a-z`, unnamed, `"0`, `"-`, `"_`, `"+`/`"*` surfaced as `Outcome.clipboard_set`), marks, `n N *` smart-case literal search with `:noh` highlights and ex line (`:w :q :q! :wq :x :e :bn :bp :s/// :%s/// :noh :set :` and `word.word` ids as `ExCommand::Command`). Markdown helpers `gx gf za` emit `ExCommand::{FollowLink, OpenWikilink, Fold}`. Every edit is a coarse document `Edit` in the outcome, grouped by `UndoGroup`; invalid input returns a message and restores the document. 341 table-driven integration cases plus a 20,000-sequence xorshift fuzz test over a multi-byte document. ### `notescraft-math` (L0) Pure TeX-math subset parser and type-setter (SPEC 30), no egui and no IO. `parse(src: &str) -> Result` covers fractions (`\frac \dfrac \tfrac`), roots (optional order), scripts (`^ _ '`, double-script errors), big operators with display limits (`\sum`-family takes them, `\int`/`\oint` keep scripts beside, matching TeX), relations/arrows/Greek/big-ops as Unicode code points (`symbols.rs`, ~140 commands), accents, font commands (`\mathrm \mathbf \mathbb \mathcal \mathscr \mathit`), `\text{}`, `\left..\right` (incl. `.` and `\{`), spacing (`\, \: \; \! \quad \qquad`) and environments (`matrix pmatrix bmatrix vmatrix cases aligned`) with `&`/`\\` cells. Caps: 20 000 chars in, nesting depth 64; errors (`Unknown`, `Unbalanced`, `MissingRight`, `DoubleScript`, `EnvMismatch`, `DepthLimit`, `TooLong`) all carry byte positions and positions are recomputed from the opening tokens, never from the scanner index. `layout(node, &dyn Metrics, Style { size_pt, display }) -> MathBox` turns nodes into `Item::Glyphs`/`Item::Rule`/`Item::Path` (surd hook, stretched parens/bracks/braces/angles as sampled polylines) in points relative to the box baseline, driven by a `Metrics` trait (glyph width, ascent, descent, x-height, rule thickness) so layout is testable without a window; TeX-like rules (Appendix G simplified): 0.7/0.5 script ladder, fraction rule on the math axis with 2-rule clearances, inter-atom spacing table (7 classes, thin/med/thick), italic correction on script bases, environments centred on the axis with 0.3 em gaps. `serde::Serialize` on `MathBox` makes the draw list ready for the `math.render` MCP surface. 54 tests: 29 unit modules, 12 layout-geometry integration cases over a 107-formula corpus, 11 error-position cases and a deterministic 20 000-input fuzz (xorshift over a TeX-ish token alphabet plus mutations of valid formulas) asserting every laid-out box is finite and never panics. ### `notescraft-index` (L1) In-memory note, link and tag index fed `(path, content)` pairs (no vault dependency). `Index::update_note` / `add_file` / `remove_note` / `rename_note` (re-keys only, never rewrites other notes), queries `notes`, `note`, `headings`, `outgoing`, `backlinks` (source, byte range, context line), `unresolved`, `orphans`, `tags() -> TagTree` (nested counts), `notes_with_tag`, `all_link_edges` and `node_ids` (stable numbering by sorted path, for the graph crate). Link resolution follows SPEC 4.3: case-insensitive basename, nearest-folder ambiguity, path suffix and vault-absolute, alias below basename, `#Heading` / `#^block` subpath validation (`Resolution::Resolved { path, ambiguous, subpath_ok }`), markdown links and embeds. Links are stored unresolved and resolved at query time, so incremental updates need no invalidation. Measured in release: 20 000 notes indexed in 123 ms, worst `update_note` 16.6 us. **Search** (`index::search`): `SearchIndex::{update, remove, rename, search, len}` is a separate in-memory inverted index with weighted fields (title x4, headings x2, tags x2, body x1), BM25 (k1 1.2, b 0.75), positions for phrases, deterministic ties. `SearchQuery::parse` supports terms, `"phrase"`, `-term`, `OR`, `path:`, `file:`, `tag:#a/b` (matches children) and `line:(a b)`; `/regex/` returns `Error::Unsupported` until a regex crate is approved. `Hit { path, score, title_match, snippets }`, at most 3 snippets with highlight ranges. Release numbers: 10 000 notes indexed in 208 ms, average search 3.15 ms. `SearchIndex` is not yet fed by `Session` (the engine owns that wiring). ### `notescraft-pdf` (L1) PDF 1.7 export, pure: markdown source in, bytes out, no IO. `export(doc_source, &ExportOptions, &dyn AssetSource) -> Result>` parses with `notescraft-markdown`, segments the note into blocks (`scan.rs`), lays them out into flat slots with real font metrics (`layout/`), paginates with widow/orphan control, table rows kept whole and headings kept with the text that follows, then paints each page into a content stream. Rendering: H1-H6 at 1.8/1.5/1.3/1.15/1.05/1.0 x base, bold/italic/strikethrough/inline-code/`==highlight==` (yellow rect), links as blue underlined text plus real `/Link` annotations for `http(s)` (wikilinks print as text), bullet/numbered/task lists with nesting (checkboxes are vector squares, never font glyphs), block quotes (left bar), the 13 callout types (tinted box + bar + type name, fixed colour table), tables (bold header, content-driven column widths with a floor, cell wrapping, rows never split mid-row), fenced and indented code (monospace, grey background, no syntax colours, hard wrap), rules, images (PNG decoded and re-encoded as `FlateDecode` with an `SMask` for alpha, JPEG passed through as `DCTDecode`; anything else is a grey placeholder box with the file name), math as italic monospace TeX source (documented limitation, `notescraft-math` is not visible at L1), mermaid/whiteboard/canvas embeds as placeholders, footnotes as a numbered list at the end, and a footer with the title and `page x of y`. Theme-independent: black on white, always. Fonts are caller-supplied (`ExportOptions::fonts: FontSet{regular, bold, italic?, mono}`, `Arc<[u8]>`); a hand-written TrueType parser (`fonts/`) reads `cmap` format 4/12, `hmtx`, `head`, `hhea`, `maxp` and `OS/2` for advances and glyph ids, and programs embed whole as `CIDFontType2` + `Identity-H` with a `ToUnicode` `CMap`, so exported text stays selectable and searchable; no subsetting means a document carries the three whole programs (flated, about 1.1 MB), and a file over `MAX_FONT_BYTES` (2 MiB) is refused. A face without a real italic file is sheared 12 degrees in the text matrix; a missing glyph is the notdef box, never a panic. Caps, all `Error::TooLarge` and never a silent truncation: 2 MiB source, 2000 pages, 128 MiB of image bytes, 200 images, 3 levels of note embeds with cycle detection. Output is byte-for-byte deterministic (`CreationDate` comes from `ExportOptions::created`); `page_count(pdf)` reads the page count back out of the bytes. The writer (`writer/`) builds the objects, xref and trailer by hand and compresses every stream with flate2 level 6. Tests: 79 (unit plus `tests/render.rs` round-tripping ten fixtures through an independent content-stream extractor that reads the xref, inflates the `ToUnicode` `CMap`s and rebuilds the text, and `tests/hostile.rs` with thirty hostile documents); the CLI and engine add their own. ### `notescraft-canvas` (L1) JSON Canvas 1.0: `Canvas { nodes, edges, extra }`, `Node` (`Text`, `File`, `Link`, `Group`, `Unknown`), `Edge`, `Color`, `Side`, `End`; tolerant `from_str`, canonical `to_string_pretty` (stable key order, unknown fields preserved), `validate() -> Vec`, `add_node` / `remove_node` / `add_edge`, `bounding_box`, `file_nodes`, `new_id(seed)` / `IdGen` (16 hex). Caps: 32 MiB, 100 000 nodes. Modules: `error`, `model`, `read`, `write`, `validate`, `id`. ### `notescraft-whiteboard` (L1) Excalidraw v2 JSON: `Whiteboard { elements, app_state, files, extra }`, `Element` + `ElementKind` (rectangle, diamond, ellipse, arrow, line, freedraw, text, image, frame, unknown), unknown fields preserved, canonical `to_string_pretty`, builders and `IdGen`. `rough::outline(&Element, Style::{HandDrawn, Clean})` returns deterministic polylines (seeded xorshift; same seed gives identical output). Caps `MAX_ELEMENTS`, `MAX_INPUT_BYTES`. Files carry the `.draw` extension in the vault; `.excalidraw` is read and written back under its own name (see `apps/notescraft-cli/src/commands/notes.rs`). ### `notescraft-diagrams` (L1, partial) Mermaid subset. `parse(&str) -> Result` with `Diagram::{Flowchart, Unsupported{kind, source}}`; flowchart supports `flowchart`/`graph` + direction, shapes `[] () {} (()) ([])`, edges `--> --- -.-> ==>` with labels (both syntaxes), chains, `&`, nested `subgraph` (depth 8); malformed lines become `Diagnostic`s; `source()` is always the input. `layout(&Diagram) -> Result` is Sugiyama-lite (iterative cycle breaking, longest-path ranks, 4 barycenter sweeps, deterministic); `to_svg` / `to_svg_with(Palette)` produce escaped SVG. Caps: 1 MiB, 5000 nodes, 20 000 edges. Sequence diagrams are parsed and laid out too (participants/actors with aliases, all arrow kinds, notes, activation, autonumber, `loop`/`alt`/`opt`/`par`/`critical`/`break` blocks, caps 200 participants and 5000 events; 500 messages lay out in 78 us). Class diagrams (UML: members with visibility, stereotypes, generics, relations `<|-- *-- o-- --> -- ..> ..|>` with cardinalities and labels, caps 500 classes/5000 members; 300 classes lay out in 1.8 ms) and state diagrams (`[*]`, transitions, composites to depth 8, choice/fork; converted to the flowchart model) are parsed and laid out as well. ER, gantt and pie are recognised but not yet parsed. ### `notescraft-backlinks` (L2) Sits one layer above `index` (it depends on it). `ContentSource` trait (no file IO; implemented for `BTreeMap`), `linked_mentions` (path, byte range, line, snippet, matched text), `unlinked_mentions` (title or alias, case-insensitive, word boundaries; skips frontmatter, code, existing links, math, comments; caps 10 000 results and skips notes over 2 MiB, reported in `UnlinkedReport`), `link_mention` (replaces a mention with `[[Title]]` or `[[Title|text]]`, fails with `Error::Stale` if the text changed), `counts`. 20 000-note scan: 12 ms. ### `notescraft-graph` (L2) `Graph::new(nodes, edges)`; deterministic Fruchterman-Reingold `layout(&Graph, &LayoutConfig) -> Vec<[f32;2]>` with a spatial grid, `Layout` for incremental `step`, pinning, `local_graph(depth)`, `degrees`, `components`, `orphans`. 5000 nodes / 15 000 edges / 300 iterations lays out in about 525 ms in release. ### `notescraft-sync` (L1) Pure sync logic shared by the future client and server, no I/O (`serde`, `sha2`, `thiserror`). `Ledger` keeps one `FileRecord` per path plus a revision counter: `apply(Write)` returns `Applied`, `Unchanged` (same hash, no revision spent) or `Conflict(current)`; a writer that saw the current `seq` wins, a stale one conflicts, a stale edit over a newer delete wins and a stale delete over a newer edit conflicts; `changes_since(seq, limit)` gives a `Changes` page with `cursor()`. `plan(local, bases, remote)` turns a disk scan, the last agreed `Base` per path and a `/changes` page into `Action`s (`Download`, `DeleteLocal`, `Upload`, `DeleteRemote`, `Reconcile`, `Adopt`). `merge3` is a line-level three-way merge that returns `Conflict` on any overlap or on input over 4 M diff cells. `conflict_path` names the conflict copy. `check_path` / `check_syncable` reject empty, absolute, `..`, backslash and control-character paths and the `.notescraft`, `.trash`, `.git`, `.obsidian` roots; `hash_hex` / `check_hash` are SHA-256 lowercase hex. 26 integration tests. ### `notescraft-sync-server` (app, exempt from layers) HTTP service over `notescraft-sync`. Env: `NOTESCRAFT_SYNC_ADMIN_TOKEN` (24+ chars), `NOTESCRAFT_SYNC_DATA` (default `./data`: `sync.db`, `blobs//`), `NOTESCRAFT_SYNC_BIND` (default `127.0.0.1:8480`), `NOTESCRAFT_SYNC_QUOTA_MB` (default 5120, 0 = unlimited). Source folders: `api/` (routes, accounts, vaults, devices, members, invites, audit, admin), `store/` (SQLite: users, vaults, devices, files, invites, pairings, audit), plus `roles.rs` (`authorize(Need::{Read, Write, Admin})`), `cache.rs` (LRU of 64 ledgers), `limits.rs` (pairing lockout: 5 failures in 10 minutes, shared by all callers, in memory), `middleware.rs` (`X-Request-Id`, one stderr line per request). Accounts: a user owns many vaults and belongs to others (roles owner, editor, viewer; non-members get 404); one device token works for every vault of its user; admin `POST /v1/users`, admin pairing codes (`?user=` optional, a well-formed new vault id is created by its first code), `GET/POST/PATCH/DELETE /v1/vaults` (device token; 50 owned per user; delete is soft for 14 days, `restore`, `purge`), members and invites (single-use 48 h codes, stored as SHA-256), `files-meta`. Sync: `GET /changes?since&limit&wait` (long poll up to 25 s), `GET|PUT|DELETE /files/{path}` (`X-Base-Seq`, `X-Mtime-Ms`, `ETag`, 201/200/409, 25 MiB cap, per-vault quota 413), `POST /batch/check`, admin `POST /v1/vaults/{v}/gc` (blobs unreferenced and past the 14-day tombstone grace). Ops: `GET /healthz`, `GET /readyz` (db write probe in a rolled-back transaction plus a blob-folder probe), admin `GET /v1/audit` (no secrets in any row). Known ceilings: blobs are never garbage-collected unless the admin runs `gc`, pairing lockout is global (no per-IP limit behind the tunnel) and in memory, blobs on local disk (MinIO later). 40 HTTP tests including the real engine client (`tests/client_e2e.rs`). History (`history/`, `store/revisions.rs`): every applied write inserts a `file_revisions` row in the same transaction as the `files` upsert; `GET /files-history/{path}` (newest first, max 200), `GET /files/{path}?seq=N`, `POST /files-restore/{path} {seq}` (writes the old bytes as a new revision, 404 unknown, 410 blob collected), per-vault `retention_days` (1..=365, default 30) with the newest 20 revisions always kept, admin `gc` removes expired revisions and unreferenced blobs, `bytes_used` counts unique live plus retained blobs. Live editing (`live/`): under `/v1/vaults/{vault}/live/{path}/` `join` (snapshot, rev, peers; viewers may join, 413 over 2 MiB, 415 non-UTF-8), `ops` (OT submit, 409 stale, 400 malformed), `events` (SSE `op`, `ack`, `presence`, `leave`, keepalive), `presence`, `leave`; rooms in memory with a 64-slot channel per peer, caps 256 rooms and 64 peers, a sweeper flushes 2 s after the last op through the same blob and ledger path as `PUT` (`flush`), a write that lost a race becomes a `conflict_path(path, "live", date)` copy. 14 tests. ### Engine sync (`crates/engine/src/sync/`) `Remote` trait with `HttpRemote` (blocking `ureq`), `pair`, `SyncState` (`.notescraft/sync/state.json`), `sync_once` (scan, changes, `notescraft_sync::plan`, perform, base copies in `.notescraft/sync/base/` for `merge3`), `SyncWorker` (one thread: debounce 2 s, 15-minute heartbeat, backoff 5 s to 5 min, phases Idle/Syncing/Offline/SignInAgain/Error, writes `.notescraft/sync/status.json`), binding and token files (`binding.json`, `credentials.json` mode 0600), conflict copies (`list_conflicts`, `resolve` Mine/Theirs/Both), commands `sync.pair`, `sync.status`, `sync.now`, `sync.unlink`, `sync.conflicts`, `sync.resolve` (gate: status and conflicts read, now and resolve write, pair and unlink delete). `Vault::sibling` gives the worker thread a writable handle without the lock. ### Vault catalog and launcher `AppSettings.vault_catalog: Vec` (a list, because the settings store merges tables key by key), `list_rows` (pinned first, folder checks inside a 500 ms budget, local sync badge from `binding.json`, `pending.json`), `touch`, `set_pinned`, `set_name`, `forget`, commands `vaults.list/pin/rename/remove`. `ui-egui` `launcher::Launcher` (desktop rail and cards, phone one column, keyboard, rename, filter) returns a `LauncherAction`; the app host (`apps/notescraft/src/launcher_window.rs`) performs it, starting vaults as separate processes. Shown when no recent vault exists, with `--launcher`, and from File > Vaults.... ### Capture (`crates/engine/src/capture/`) `note.capture {text, title?, target?, source?, attachment?, utc_offset_minutes?}`: `target` is `daily` (default: appends `- HH:MM text` or a `### HH:MM title` block to today's daily note, through the open buffer when there is one so undo works), `inbox` or a vault folder (creates `/ .md` with frontmatter `created` and `source`, numbering a taken name); `attachment {name, base64}` is saved through the attachment helper and embedded. Caps: text 1 MiB, title 200 and source 2000 characters, attachment 20 MiB; absolute, `..`, hidden and reserved targets are refused. Gate: write. ### `notescraft-collab` (L1) Live co-editing core, no I/O: `TextOp` (Retain/Insert/Delete over Unicode scalar counts; wire format `[5,"hi",-2,3]`), `apply`, `compose`, `invert`, `transform` (TP1 property-tested), `transform_index`, `Selection`, `diff`, the OT `Client` state machine, the server `Room` (history cap 1000, size cap 2 MiB), `Presence` (64 peers, sanitised names). 22 tests. ### Compact (phone) layout `shell/compact/`: below 600 pt one page at a time (`CompactPage` Notes, Note, Search, Graph, Calendar, More), bottom tab bar (88 pt, 44 pt hit areas), back chevron top bar; `UiState.compact_page` persisted. ### `notescraft-templates` (L2) No chrono: own `Date`/`DateTime`/`Weekday` (civil-from-days), `format()` (Moment token subset), `expand(template, &Context)` for `{{title}}`, `{{date[:FMT]}}`, `{{time[:FMT]}}`, `daily_note_path`, `parse_daily_name`, `month_grid` for the calendar widget. ### `notescraft-lint` (L2) and `notescraft-blocks` (L1) `lint` is pure (no IO): `check(text, &Context) -> Vec` with rules `heading-increment`, `list-marker`, `double-space`, `trailing-space`, `blank-around-headings|fences|lists`, `frontmatter-unclosed`, `frontmatter-duplicate-key`, `duplicate-heading`, `unresolved-link` (closest title by edit distance, never a safe fix) and a `spelling` slot that never reports. A `Fix` is edits plus `safe`; `fix::apply` returns `Error::Stale` when a range no longer fits, `apply_all_safe` applies back to front and skips overlaps. `style::Vote` learns list marker, heading style and date format per note, incrementally (20 notes, 80%). Engine `crates/engine/src/lint/`: `lint.check|fix|fix_all_safe|rules|dismiss|state|state_clear|style`, dismissals in `.notescraft/lint-state.json`, `session.lint.*` config (enabled, learn_style, learn_list_marker, learn_date_format, fix_on_save, disabled, severities); checks are reads for the assistant gate, fixes writes; MCP aliases `lint_check`, `lint_fix_all_safe`. `blocks` parses top-level blocks (paragraph, heading, quote, callout, task, list item with children, table, code fence, divider, frontmatter, embed) and returns one `Edited` replacement per operation (`move_block`, `duplicate_block`, `delete_block`, `turn_into`, `insert_component`), so a UI applies one undo step; `notescraft-editor` re-exports it as `editor::blocks` and adds `marks` (toggle bold, italic, strike, code, link, highlight). `notescraft-blocks` also owns `detect`: `detect_language(body) -> Option<&'static str>` names the language of an unlabeled fence only on strong evidence, so a batch review never offers a guess it cannot defend. The rules run in order and the first match wins: shebang (`python`, `bash`/`sh`/`zsh`, `node`), `, Limits)` validates the module, requires the ABI v1 exports and memory, and links only the 11 `ns` imports (any other import is an error). Permissions are checked before the host trait is touched. `Limits` defaults: fuel 50 M, memory 64 MiB, 2 s, 4 MiB results, 8 host calls/ms; breaches are `Error::PluginLimit` / `PluginTrap`, and three consecutive failures disable the plugin. `PluginSet` lists commands as `plugin..` and dispatches by that id. Guest pointers are bounds-checked with `get`. 15 `wat` fixtures cover hostile modules (loops, memory growth, bad pointers, oversize results, missing exports, wrong ABI, foreign imports, traps). ### `notescraft-engine` (L4, partial: registry + session) Generic command registry `Registry` (the context `C` becomes `Session` later). `CommandSpec` has id, label, menu path, shortcut, typed `ParamSpec`s, `enabled` and `run` as plain function pointers. `register` validates ids (`[a-z0-9._-]`, 1..=128, no empty segments, no duplicates, cap 10 000); `register_plugin_command` forces the `plugin..` prefix and `unregister_prefix` removes a plugin's commands. `list()` returns serialisable `CommandInfo` sorted by id (for CLI `commands` and MCP `list_commands`). `run` checks, in order: unknown id, size cap (4 MiB, iterative walk), object-or-null, enabled, required/typed/unknown params, then calls the command inside `catch_unwind` so a panic becomes `Error::Panicked(id)` and the registry stays usable. `panic_hunt::adversarial_params()` plus a test runs 12 sample commands against 22 hostile inputs. `Session::open(root)` opens the vault and indexes every note (unreadable files become `diagnostics()`, never an abort); `open_read_only`. Buffers: `Buffer::edit` with the markdown range rules, undo/redo (1000 steps, 50 MiB), dirty tracking, `open_buffer`/`close_buffer(force)`/`save_buffer` (through `Vault::write`, then re-index). Note operations: `read_note`, `write_note`, `create_note`, `delete_note` (to trash), `rename_note(from, to, update_links)` which rewrites inbound links (keeps alias and `#heading`/`#^block`, keeps bare vs path form) all-or-nothing (`Error::RenameIncomplete`). `apply_event(VaultEvent)` re-indexes external changes and records a conflict instead when the buffer is dirty. `register_core` registers 16 commands: `vault.info`, `note.{list,read,write,create,delete,rename}`, `links.{outgoing,backlinks,unresolved}`, `tags.list`, `buffer.{open,edit,save,undo,redo}`; write commands are disabled on read-only sessions. Wiring slice: `SessionConfig` (serde, validated paths: daily folder/format/template, templates folder, attachment folder, update-links-on-rename, theme, week start) with `Session::open_with`; the `SearchIndex` is kept in sync with every write, rename, delete, save and external event (`search` command, default limit 20, max 200); daily notes from a template (`daily.open`, `calendar.month`, `template.list`, `template.create_note`, `template.insert` through the buffer so undo works); themes (`theme.list`, `theme.get`, `theme.set`, unknown id falls back to `dark` with a diagnostic). 25 command ids in total. Settings: `AppSettings` implements `craft_settings::Settings` (app id `notescraft`, schema 1): the `SessionConfig`, an opaque UI blob (cap 256 KiB), recent vaults (max 20), assistant settings (agent, command, review/write/delete flags, per-plugin tool toggles), enabled plugins and hotkeys, with `validate()` repairs; `load`/`save` go through the craft-settings store (atomic, backup, recovery; `NOTESCRAFT_CONFIG_DIR` override for tests); commands `settings.get`, `settings.set`, `settings.reset`. `Index::remove_file` exists now. Properties (SPEC 6 item 20): `note.properties` lists `{key, type, value, raw}` rows in file order plus `has_frontmatter`, importing Obsidian's `.obsidian/types.json` (read-only, junk ignored); `note.property_set` validates the value against the type (explicit, from types.json, or inferred), then `note.property_remove` and `note.property_rename` rewrite exactly one frontmatter entry through `notescraft-markdown`; a write goes through the open buffer as one undoable dirty edit (undo keeps the editor's own edits, disk updates on save) or through `write_note` with re-indexing, and invalid values or a read-only session refuse before anything is written. Gaps: plugins and the control channel are not wired into `Session` yet. **Attachments (SPEC 9):** `attachments.rs` saves pasted or dropped bytes with `Session::save_attachment(hint, ext, bytes)` (`save_attachment_at` takes an injected clock, `save_attachment_from_path` copies an outside file, size-checked by metadata first). The type must be one of 15 allowed extensions (`unsupported_attachment_type` otherwise); a magic-byte sniff (png, jpeg, gif, webp, pdf) fixes a mismatched declared extension, an `.svg` must look like an SVG document, and anything over the `files.max_attachment_mib` cap (default 64 MiB, validated and repaired like every session setting) is refused with `attachment_too_large` before any byte is written. The hint is sanitised (last path component, no control characters, leading dots dropped, 120-byte stem, extension forced), an unnamed paste becomes `Pasted image YYYYMMDDHHMMSS.`, and collisions get ` 1`, ` 2`, … suffixes, never an overwrite. The folder is created through the vault guard (reserved dirs refused), the file is written through `Vault::write` (craft-atomic) and registered with `Index::add_file`, and the returned `SavedAttachment { path, link }` carries the `![[name]]` embed (bare name when unique in the vault index, vault-relative path otherwise). Commands `attachment.save` (a base64 payload through the strict hand-written decoder in `base64.rs`, 96 MiB decoded cap, correct padding required, ASCII whitespace skipped) and `attachment.save_path`; both writable-gated, and `attachment.save` is the MCP alias `save_attachment`. **Slash menu (SPEC 21):** `slash.rs` holds the menu data: `SlashEntry { id, label, keywords, icon, insert }`, `filter_entries` (case-insensitive prefix beats word-start beats in-order subsequence, ties in menu order) and `trigger_ok(line, col)` (the `/` opens the menu at line start or after a space or tab, never inside inline code, math, an open `[[..]]` or `(..)` link target, or a URL; 41-case table). `slash_menu.rs` is the fixed entry table; `slash_commands.rs` registers the commands, each an ordinary command on menu `["Insert",