# NotesCraft registry: separable add-ons, and an honest debloat plan Status: draft for owner review (2026-10-21). Numbers: `measurements.md`. Tasks: `tasks.md`. Owner request: "separate some of the plugins and certain things from the current application, add them to the notescraft registry, to debloat the app so it doesn't feel heavy". ## 0. Verdict first 1. **The registry is the right mechanism for choice and growth, not for the weight the app has today.** Measured on this tree: binary 38.4 MiB, RSS 131 MB idle (physical footprint 72 MB), 0.3 s CPU to idle. Everything that could move to a registry today (extra grammars, math font, Lottie icons, Lucide long tail, extra themes) is **about 0.7 MiB, 2 % of the binary**, and moves no startup or RSS number you can feel. 2. **The real, cheap debloat needs no registry** and is about 10 MB (25 %): strip symbols on macOS and Windows (-5.4 MB measured), fonts (-0.8 MiB duplicate Inter, about -1 MiB unused egui default fonts, -0.26 MiB duplicate mono), `opt-level = "s"` (-2.8 MB more, perf unmeasured). Fonts are 4.2 MiB, 11 % of the file, more than all the optional crates together. 3. **Why it "feels heavy" is vault data, not features**: a 6500-note vault peaks at 494 MB while indexing and settles at 220 to 380 MB. No add-on mechanism changes that; it needs its own spec (open question 9). 4. **The wasm plugin host is not wired into the app at all** (`Session::load_plugins` has no non-test caller; `wasmi` is 0 bytes in the shipped binary). The registry cannot deliver plugins until that is wired, and wiring adds an estimated 0.8 to 1.2 MiB. Order of work matters: do the no-registry savings first, or the registry makes the binary bigger before it makes it smaller. 5. The registry still earns its place: it stops the binary growing (every new grammar, theme, template, small command lands outside it), gives users choice, and finishes a half-built feature (`grammar.install`, an empty `notescraft-registry` repo, `plugins/dice`). So the plan has three layers, in this order: **P0** size fixes without a registry; **P1** a small, signed, offline-first registry (one engine module, no new crate); **P2** move the few things that fit, to prove the pipeline and bank the 35 ms and 0.7 MiB. ## 1. What exists today (reuse, do not rebuild) - Registry repo `https://git.marcospaulo.dev.br/masi/notescraft-registry` (local clone `/Volumes/Storage/Projects/notescraft-registry`), `index.json` with four empty lists, README schema. **Schema mismatch to fix**: the README names the key `name`, the engine (`crates/engine/src/grammars/mod.rs::registry_entry`) reads `id`. No entry was ever published, so no compatibility burden. - Grammar flow: `grammar.info|install|install_text|remove`, gates in `crates/syntax/src/{fetch,validate}.rs` (https only, no credentials or IP literals, host allow-list `REGISTRY_HOST`, no redirects, 512 KiB cap, sha256, parse and sample), transport `Curl` (a `curl` subprocess) behind a `Transport` trait, state in `AppSettings::grammars` (max 64) and files in `/grammars/`. - Themes: user themes are `/themes/*.toml` (`prefs/themes.rs`, max `MAX_USER_THEMES`, ids may not reuse a built-in). - Plugins: `crates/plugins` (wasmi host, ABI v1, manifest with `permissions` allow-list), folder `/.notescraft/plugins//{manifest.json,plugin.wasm}`, approvals kept in settings, sample `plugins/dice` (4.8 KB). No view, panel, settings or event hooks (ABI v1); UI-contribution ABI is roadmap item 25. - `sha2` is approved; `ureq` (rustls, which brings `ring`) is already linked into the engine; `craft-atomic` offers `atomic_write(path, bytes)` and `write_new(path, bytes)`. - Core plugin cards in Settings (Calendar, Templates, Diagrams, Whiteboard) only toggle features that are compiled in. They are organisational, not size. - Name clash to avoid: `crates/engine/src/registry` is the **command** registry. The new engine module is `addons/`; command ids are `registry.*`. ## 2. Classification of everything that could move Legend: **core** always in the binary; **lazy** stays in the binary, initialised on first use; **feature** cargo feature (compile-out); **pack** downloadable assets; **wasm** plugin on ABI v1; **UI-ABI** blocked on roadmap item 25. "Effect" is measured unless marked est. | Item | Class | Effect on size / startup / RSS | Honest note | |---|---|---|---| | egui, wgpu, winit, text shaping, image codecs, serde, regex, TLS | core | 9 MiB of `.text` | cannot move | | `ui-egui` (4.2 MiB), `engine` (1.8 MiB), vault, markdown, index, editor, i18n, themes, search, backlinks, links, tags, properties, bases core, templates, tasks, lint, blocks | core | | the product | | Inter Regular/SemiBold/Medium, JetBrains Mono NoLigatures | core | | | | Duplicate Inter Regular + SemiBold in `engine` PDF export | **fix in place** (share one `const`) | -0.79 MiB | no registry | | PDF export JetBrains Mono Regular | **fix in place** (use NoLigatures) | -0.26 MiB | no registry | | egui `default_fonts` (Hack, Ubuntu-Light, emoji-icon-font; keep NotoEmoji) | **fix in place** (drop the eframe feature, embed NotoEmoji only) | about -0.95 MiB (est) | needs a snapshot check for tofu | | Symbols (`__LINKEDIT`) | **release profile** `strip = "symbols"` | **-5.38 MB** | losing symbol names in crash backtraces is the cost | | `opt-level = "s"` | **release profile** | -2.8 MB more | measure index build and scroll first | | syntect default pack (75 grammars, 0.37 MiB, built on first fence: 75 ms) | lazy (already) | | trimming it needs a custom dump; skip | | 25 NotesCraft grammars (Twig, Blade, Svelte, Vue, Nix, Zig...) | **pack** `grammar` | -69 KB, **-35 ms** first highlight, repeated at every `set_installed` | best first mover: tiny, pure text, proves the pipeline | | STIX Two Math font (math only) | **pack** `font` | -0.36 MiB | math falls back to Inter/JetBrains glyphs and boxes until installed; needs the seed (section 11) | | Lottie icon animations (65 JSON, 0.14 MiB) | **pack** `pack` | -0.14 MiB | static icons already exist; animation is polish | | Lucide long tail (278 catalogue vs the ~89 used) | pack | at most -0.07 MiB | not worth the complexity; skip | | `en.toml` (61 KB) | core | | future locales ship as `pack` (language packs): zero cost now, the one place the registry clearly pays | | Extra built-in themes (Frappe, Macchiato, Solarized x2) | **pack** `theme` | under -10 KB | worth it only as the first theme entries, for the browser to show | | Community-style themes, template sets, snippets | **pack** / `theme` / `snippet` | 0 (they never were in the binary) | the real value of a registry | | Extra Hebrew/Arabic system fallback read at launch (SFHebrew, SFArabic) | lazy | -0.8 MB RSS, 1 disk read | use the existing script-on-demand loader | | CJK and other script fonts | none | | already **not** bundled: `fonts::install` loads system fonts per script on demand. A Noto download pack is only for Linux without them; defer | | Wasm plugin host (`wasmi`) | **feature** `plugins`, default on | 0 bytes today (not linked), -37 CPU-s of compile; +0.8 to 1.2 MiB (est) once wired | wire it first (task 12), then the feature exists for CLI/server builds | | Dice roller, Citations (roadmap 10), word tools, small text commands | **wasm** (ABI v1: vault.read, vault.write, editor) | 4.8 KB each, 0 in the binary | the only real "plugins" that fit today | | Importers (Notion zip, ENEX, Roam) | needs a host call for zip/file import, or stays in-tree | | ABI v1 has no file import and no binary input: **not** wasm yet | | Calendar (170 KB + 2.9k UI lines + 2k engine lines), Whiteboard (57 KB + 5.9k UI), Diagrams, Canvas, Graph, Bases Map/Cards, Kanban, Slides, Charts, Mind map, Maps | **UI-ABI** (item 25) to become wasm; **feature** to compile out | all optional crates: 1.15 MiB `.text`; with their UI and engine halves 2.5 to 3.5 MiB (est), 7 to 9 % | extracting them to wasm needs views, panels, settings, events. Cargo features alone save little for the desktop app and cost a large refactor; only do it for the CLI/server builds | | Vim, assistant (ACP), knowledge (RAG), pdf export, git, collab | **feature** candidates | 0.1 to 0.4 MiB each, est | organisational for the desktop app; useful for slim CLI/MCP builds | | Map tile providers | **snippet** presets | 0 | network policy already opt-in (bases D13); a preset list is data | | Pandoc | external helper, detected on `PATH` | 0 | **do not** download executables in v1 (open question 5) | | Video | external player via `shell/app/video/launch.rs` | 0 | already a helper, not bundled | | OCR backend (attachment-ocr spec) | helper binary pack, later | | needs its own trust model; out of scope here | What actually reduces what: - **Binary size**: P0 (about 10 MB), then fonts-as-pack and Lottie (0.5 MiB). Cargo-feature compile-outs are the only big code lever and are a refactor, not a registry job. - **Startup**: the grammar rebuild (-35 ms of 110 ms on first fence) and lazy fallback fonts. Nothing else measured moves. - **RSS**: nothing in this spec measurably does; the levers are index memory and egui/GPU surfaces. - **Only organisational**: core plugin cards, moving crates between folders, the Lucide tail. ## 3. Goals and non-goals Goals: a signed, static, offline-first catalogue of add-ons the user installs on purpose; one engine surface (commands, CLI, MCP, UI); zero loss for anyone upgrading; hostile content cannot write outside its own folder or run anything without consent. Non-goals (v1): third-party publishing flow or accounts; a download counter; payments; automatic code execution; executable downloads (pandoc, ffmpeg); archive formats; plugin sandbox changes; UI-contribution ABI (item 25); a custom registry URL (open question 4). ## D1. Hosting and index - **v1 host: the existing Gitea repo** `masi/notescraft-registry`, raw layout `raw/branch/main/`. No server work, history is the audit log, the host allow-list `REGISTRY_HOSTS = ["git.marcospaulo.dev.br"]` already exists (becomes a list). Mirror later by adding a host to the allow-list and `url` in entries. - **Optional later: the sync server** serves the same tree from a directory with one read-only route `GET /registry/{*path}` (about 30 lines in `apps/notescraft-sync-server/src/api/routes.rs`, public like `/p/{slug}`, no auth, no vault data, file allow-list by extension, caches by ETag). Not needed for v1; listed because the owner asked whether it could host one: yes, but a git repo is simpler and already trusted. - Files: `index.json` at the root; payload files under `files////`. - The app caches the last verified index at `/registry/index-cache.json` with `fetched_at` and `etag`; a conditional GET (`If-None-Match`) keeps checks near-free. - Index cap 1 MiB, at most 2000 entries; a bigger or non-JSON index is refused and the cache stays. ## D2. Index schema v2 ```json { "version": 2, "generated_at": "2026-10-21T12:00:00Z", "entries": [ { "id": "twig", "kind": "grammar", "title": "Twig", "summary": "Twig templates", "version": "1.0.0", "min_app_version": "0.1.0", "license": "MIT", "source": "https://github.com/...", "aliases": ["twig", "html.twig"], "suggested": false, "permissions": [], "files": [ { "path": "twig.sublime-syntax", "size": 3912, "sha256": "<64 hex>", "url": "https://git.marcospaulo.dev.br/masi/notescraft-registry/raw/branch/main/files/grammar/twig/1.0.0/twig.sublime-syntax" } ], "signature": { "key_id": "k1", "sig": "" } } ], "revoked": [ { "id": "old-thing", "version": "0.9.0" } ] } ``` Fields (all required unless "optional"): `id` `[a-z0-9-]{1,48}` unique per `kind`; `kind` one of `plugin theme pack grammar font snippet`; `title` 1..80; `summary` 0..200 (optional); `version` `MAJOR.MINOR.PATCH`, each 0..9999, no pre-release; `min_app_version` same shape; `license` SPDX-like 1..40; `source` https URL (optional, display only); `aliases` (grammar: fence labels; others: search words), at most 16, each `[a-z0-9._+-]{1,32}`; `suggested` bool (optional, first-run list); `permissions` (plugin only, must be a subset of the known `Permission` strings; unknown rejects the entry); `files` 1..64, each with `path`, `size`, `sha256`, `url`; `signature`. The README's `file` becomes `files[].url` relative form is **not** allowed: urls are absolute https on an allow-listed host. Derived, never in the file: total size = sum of `files[].size`. Caps per kind (sum of file sizes): plugin 4 MiB, theme 64 KiB, grammar 512 KiB (the existing cap), snippet 1 MiB, pack 8 MiB, font 32 MiB. One file at most half of the cap of 32 MiB for fonts, otherwise the whole cap. All checked against both the index size and the bytes actually received. Validation lives in `addons/index.rs`; one broken entry is skipped with a diagnostic, the rest of the index loads (same rule as plugin folders). A wrong `version` at the top rejects the index. ## D3. Trust and signatures - Every entry carries an **ed25519 signature** by the owner-held key over a canonical text: `notescraft-registry-v1\n\n\n\n` then for each file sorted by `path`: `\n\n\n`. The signature therefore covers every hash and size; the index itself can stay unsigned (tampering with it can only add entries whose signature fails, which is a denial of service, not code execution). - The app embeds the public keys: `addons/keys.rs` `TRUSTED_KEYS: [(&str, [u8; 32]); N]`, `key_id` selects one, so a rotation is a release that adds `k2` and later drops `k1`. The private key never enters this repo; it lives with the owner. Signing happens in the registry repo with a small tool (`xtask registry-sign` over a directory, task 18). - `revoked` lets a release be pulled; an installed revoked version is marked and the UI offers Remove or Rollback. The index is unsigned, so an attacker can hide `revoked`; stated, accepted for v1, solved by signing the index (open question 2). - **Dependency question for the owner**: verification needs an ed25519 implementation. Options: (a) depend on `ring` directly, already in the lockfile and linked through `rustls`, adds roughly nothing to the binary; (b) `ed25519-dalek`; (c) no signatures, rely on HTTPS plus sha256 plus the host allow-list. **Recommendation: (a)**, written approval needed (`AGENTS.md` rule on new dependencies). Until answered, task 3 is blocked and installs must be refused for entries without a valid signature, so the feature cannot ship half-secure. - A sideloaded or seed file (section 11) is verified with the same signature check; there is no "trust me" path. - Integrity beyond signatures: plugin manifests inside the downloaded files must match the index entry (`id`, `version`, `permissions`, commands), or the install fails. ## D4. Local layout All add-on state lives under the app data dir, never in a vault (so a synced vault cannot ship code): ``` /registry/ data dir = craft_settings::paths::data_dir("notescraft"), override NOTESCRAFT_CONFIG_DIR state.json installed table, written with craft-atomic (version 1) index-cache.json last verified index + fetched_at + etag store//// immutable once state points at it; files exactly as in the entry staging// downloads in progress; emptied at every start ``` `state.json`: `{ "version": 1, "installed": { ":": { "version", "previous": "1.0.0"?, "source": "registry|legacy|seed|sideload", "installed_at", "sha256_of_files": "" } } }`. At most 512 installed entries. Per-vault file `/.notescraft/registry.json` (written with `Vault::write_private`): `{ "version": 1, "enabled": ["plugin:dice"], "wanted": ["grammar:twig"] }`. - `enabled`: plugins on in this vault (plugins are the only kind that is per-vault; permissions approvals stay in settings as today). - `wanted`: ids this vault has been seen to use (a Twig fence). A new machine opening the vault shows one banner "This vault uses 2 add-ons" with an Install button. Nothing is installed from it automatically. Grammars, themes, fonts, packs and snippets are **global** (user level); enabling = installed. Rollback keeps one `previous` version dir per id. ## D5. Network policy No network unless one of three things happens, all user-initiated: 1. the user opens Settings > Registry (one conditional GET of `index.json`; not at app start); 2. the user presses Install, Update, Check or an install button on a card; 3. the setting `registry.update_checks` is on (default **off**): at most one conditional GET per 24 h, started after the window is idle, no identifiers, `User-Agent: NotesCraft/`, no vault data, no cookie. Everything else (search in the browser, first-run suggestions, "this vault uses X") reads the cached or seed index and works offline. A failed fetch leaves the cache and state untouched and shows "Offline" (the `offline` error code exists). Same gates as grammars on every URL: https only, host allow-list, no credentials, no IP literal, no redirects (`syntax::validate::check_url`). Transport: `ureq` (already linked, no subprocess); the `Transport` trait stays injectable so tests never touch the network. ## D6. Install, update, remove, rollback All entry operations are one function `addons::install::run(plan)` over an injected `Transport`: 1. Resolve the entry from the cached or fresh index; refuse if `min_app_version` is newer than the app, if `revoked`, if the kind has no consumer yet, if a file list check fails (D7), or if the signature does not verify. 2. Check free disk space (twice the entry size) and the installed-table cap. 3. Download each file to `staging//` through the transport: streaming, hard cap per file and per entry, sha256 computed on the bytes actually received (a lying `Content-Length` does not help), abort on the first mismatch. 4. Per-kind content check on the staged bytes (D8). A failure deletes staging; nothing else changed. 5. Write the final tree `store////` with `craft_atomic::write_new` per file (refuses to overwrite). The version directory is unreferenced until step 6, so a crash leaves garbage, not a half-install. Garbage (version dirs not in state, staging) is removed at the next start. 6. Flip `state.json` with `craft_atomic::atomic_write` (the only commit point): set `version`, keep the old one in `previous`. 7. Tell consumers to reload (grammar set rebuild, theme list, plugin set). `update` = install of a newer version of an installed id. Downgrade is never an update; only `rollback`. `remove` deletes the state entry first (commit point), then the store dirs, then reloads consumers; blocks of a removed grammar fall back to plain monospace (existing behaviour), a theme in use falls back to the built-in of the same variant with a notice, a plugin is unloaded. `rollback` swaps `version` and `previous` in `state.json` (the old dir is still there) and reloads. ## D7. Hostile input (no archives by design) Entries are file lists, not archives: **no zip or tar anywhere**, which removes zip-slip, symlink and decompression-bomb classes without a parser. What remains, all in `addons/fileset.rs`, pure and unit-tested: - `path`: relative, `/`-separated, 1..4 components, each `[A-Za-z0-9._-]{1,64}`, no component empty, `.` or `..`, none starting with `.`, no `\`, no `:`, no NUL, no Windows reserved device name (`CON`, `NUL`, `COM1`...) as a component, no case-insensitive duplicates, extension on the allow-list of the kind (plugin `manifest.json` and `plugin.wasm` and optional `README.md`; theme `.toml`; grammar `.sublime-syntax`; font `.ttf` `.otf`; snippet `.md` `.txt` `.toml`; pack: each file under `grammars/`, `themes/`, `templates/` or `icons/` with its own extension list). The joined path is built by the code from validated components, never by string concat of server text. - Sizes: per kind caps from D2, at most 64 files, no zero-size file, sum with `checked_add`. - URLs: `check_url` plus `url` host in the allow-list, length cap, no query string. - Download: streamed with a hard cap; received bytes and sha256 must match the entry; a body shorter or longer is a failure; slow-loris bounded by a total time limit (30 s per file, 5 min per entry). - Never execute: nothing from the store is run except `plugin.wasm` through the existing sandbox (fuel, memory, deadline limits, 3-failure auto-disable) and only after the user approved its permissions. - Writes go to `/registry/` only; the reserved vault paths `.obsidian/` and `.notescraft/` rules are unchanged; `.notescraft/registry.json` is written only through `write_private`. - JSON bounds: index and state parse with the existing `serde_json` depth limit, strings capped, unknown `version` is `Err`, nothing silently repaired. ## D8. Kinds | Kind | Installed as | Consumer | Per-kind check | |---|---|---|---| | `grammar` | `store/grammar///.sublime-syntax` | `syntax::load::set_installed` fed from state (legacy `AppSettings::grammars` also read) | `syntax::validate::check_grammar` with the entry's `sample` | | `theme` | `store/theme///.toml` | `Session::user_themes` scans state too | `Theme::from_toml`; zero hard diagnostics; id equals entry id; no built-in id | | `plugin` | `store/plugin///{manifest.json,plugin.wasm}` | the plugin loader reads installed plugins listed in the vault's `enabled`; approvals as today | `Manifest::parse`, manifest equals entry, a dry `Plugin::load` with default limits | | `pack` | several files under `grammars/ themes/ templates/ icons/` in one version dir | each consumer reads the pack's subfolder | every file runs its own kind check | | `font` | `store/font///` | the script-on-demand font loader may use it before system fonts; not required for v1 consumers | `ttf-parser`-free check: magic bytes `00 01 00 00`, `OTTO` or `true`, size cap (no new dependency) | | `snippet` | `store/snippet///*.md` | templates list (insert from the template picker) | UTF-8, size cap, no NUL | Kinds `font` and `snippet` are accepted by the index and the installer so the schema is complete, but a kind with no consumer yet is shown as "Not available in this version" and cannot be installed (`D6` step 1). The first consumers to build are grammar, theme, plugin, pack. ## D9. Engine commands (all in `crates/engine/src/addons/commands.rs`) Group registered by `register_addons(reg)` called from `commands.rs::register_core`. Ids use the user-facing prefix `registry.`. | Id | Params | Does | Network | Gate class | |---|---|---|---|---| | `registry.list` | `kind?`, `query?`, `state?` (`installed`, `updates`, `available`), `limit?` | cached or seed index joined with `state.json`; each row: id, kind, title, version, installed version, size, state (`available`, `installed`, `update`, `revoked`, `incompatible`, `unsupported`), permissions | no | READ | | `registry.info` | `kind`, `id` | one entry in full: files, sizes, license, source, signature key id, what a plugin may do | no | READ | | `registry.check` | none | conditional GET of the index, verify, replace the cache, return update counts | yes | DELETE-class (see below) | | `registry.install` | `kind`, `id`, `version?` | D6 | yes (or seed) | DELETE-class | | `registry.update` | `kind?`, `id?` (none = all with updates) | D6 per entry | yes | DELETE-class | | `registry.remove` | `kind`, `id` | D6 | no | DELETE-class | | `registry.rollback` | `kind`, `id` | D6 | no | DELETE-class | | `registry.enable` / `registry.disable` | `id` (plugin) | per-vault `enabled` list; enabling asks the existing permission approval | no | WRITE | | `registry.suggest` | `label` or `kind`+`query` | alias lookup in the cached index; used by the grammar card | no | READ | Assistant gate (`crates/engine/src/ai/assistant_gate.rs`): `registry.list`, `registry.info`, `registry.suggest` join `READ_COMMANDS`. `registry.enable|disable` join `WRITE_COMMANDS`. The five that fetch or remove third-party code (`check`, `install`, `update`, `remove`, `rollback`) join `DELETE_COMMANDS`, so an assistant needs both `allow_write` and `allow_delete`, the same treatment as `sync.pair` and `server.connect` (trust-expanding actions). Test: `tests/core_commands.rs` `CORE_IDS` and a gate test per id. Errors (`CommandError` codes): `registry_refused` (a gate failed, message names it), `offline`, `not_found`, `incompatible` (app too old), `revoked`, `limit`, `disk_full`, `needs_dependency` (no signature verifier yet). Existing `grammar.*` commands stay as thin aliases that call the same code and keep their codes. CLI parity: `notescraft run registry.install --params '{"kind":"grammar","id":"twig"}'` already works through the generic `Run`; task 9 adds `notescraft registry list|info|check|install|update|remove|rollback` as sugar in `apps/notescraft-cli/src/args.rs` and `commands/`, printing id, version, size, state. MCP gets them through the registry automatically. ## D10. UI (Settings > Registry) New Settings category in `views/settings_view/categories.rs`, view folder `views/registry_view/` (library components only: Button, Input, Switch, Modal, ConfirmModal; i18n keys under `[ui.registry]`; theme tokens only). - Top row: search Input, kind chips (All, Plugins, Themes, Packs, Grammars, Fonts, Snippets), "Check for updates" Button, "Last checked " or "Never", Switch "Check for updates automatically" (off). - List rows: title, kind chip, version, size, short summary, state: `Install` Button, `Installed` check, `Update to 1.2.0` Button (accent), `Needs NotesCraft 0.3`, `Revoked`, `Not available in this version`. Update badge: a count dot on the Settings entry and on the category, only after a check. - Detail drawer: summary, license, source link, files and sizes, **permissions as sentences** ("Read notes and search", "Create and change notes", "Edit the open note") for plugins, signature key id, Remove and Rollback Buttons. - Offline banner when the last fetch failed; the list then shows the cache. - Plugin install ends with the existing permission approval modal, then an Enable Switch for the current vault. - **First-run suggestions**: once, after the first vault opens following install or upgrade, a dismissible card "Add-ons you may want" listing the entries with `suggested: true` from the seed index plus anything `registry.suggest` found for this vault. It never fetches; its Install buttons do (D5 trigger 2). - Code card (`views/note_view/blocks/code_card.rs`): the existing "Add grammar" link becomes "Install Twig grammar (3.9 KB)" when `registry.suggest` finds the label, falling back to today's dialog (paste a link) when it does not. - Snapshot example flag `--registry` for visual checks; the screens follow the PaperCraft tokens (rounded cards on canvas, tone not borders). ## D11. Migration: nobody loses anything Rule: **nothing leaves the binary in the same release that adds its registry entry**, and every removal ships with a path that needs no thought. 1. Release N adds the registry, adopts legacy grammars (`source: legacy`, shown as installed, no update), keeps everything bundled. 2. Release N+1 removes an item from the binary only if all hold: its entry is published and signed; the **seed** copy (below) is in the installer; a migration test exists. 3. **Seed**: the installer carries `registry-seed/{index.json, files/...}` next to the binary (macOS `Contents/Resources/`, Windows and Linux beside the exe), a verified signed snapshot of the moved items only (about 0.6 MiB). `install` prefers the seed file when its sha256 equals the entry, so moved items install **offline, instantly**. Seed items are not auto-installed. The DMG size does not change; the binary shrinks. 4. **Auto-offer by use**, no network: - a fence label that is no longer bundled: the code card asks `registry.suggest` and shows "Install Twig grammar", one click; with `registry.auto_install_for_vault` on (default **off**, open question 3) it installs from the seed without asking; - first launch of N+1: a background scan (ripgrep crates already in the engine, 3 s budget, once per vault and per registry generation) of fence labels and `$` math in the opened vault records `wanted` in `.notescraft/registry.json` and shows one banner "This vault uses Twig, Blade (2 add-ons, 7 KB): Install"; - a selected theme that moved: the app keeps the id, falls back to the built-in of the same variant until installed, and the Settings theme picker shows it with an Install button; - math without the font: formulas fall back to the Inter/JetBrains glyph chain (already the last resort) and the math view shows an Install button. 5. Tests (task 19 to 21 each): for every moved id, `registry.suggest` finds it, the seed verifies, `registry.install` from the seed in an offline temp session makes `grammar.info` report installed, and removal returns the old fallback behaviour. ## D12. First-party content, ordered by measured benefit | # | Item | Size | Gain | Needs | |---|---|---|---|---| | P0.1 | `strip = "symbols"` in the release profile (macOS, Windows) | - | **-5.38 MB** | nothing | | P0.2 | share one Inter Regular/SemiBold `const`, PDF mono = NoLigatures | - | -1.05 MiB | brief 29 | | P0.3 | drop eframe `default_fonts`, embed NotoEmoji only | - | about -0.95 MiB | snapshot check | | P0.4 | `opt-level = "s"` | - | -2.8 MB | perf measurement | | P1.1 | wire `load_plugins` (+ ~1 MiB, est) | | enables plugins | task 12 | | P2.1 | pack `grammars-extra` (25 grammars as 25 `grammar` entries) | 69 KB | -69 KB, -35 ms first highlight | tasks 19 | | P2.2 | font `stix-math` | 376 KB | -0.36 MiB | task 20 | | P2.3 | pack `icons-animated` (Lottie) | 148 KB | -0.14 MiB | task 21 | | P2.4 | plugin `dice` (exists), then `citations` | 4.8 KB | 0 binary, first real plugin | task 22 | | P2.5 | themes Frappe, Macchiato, Solarized | under 10 KB | negligible, fills the browser | task 22 | | later | language packs when a second locale exists | 60 KB each | keeps `en`-only core | | P2.x together: about 0.6 MiB, 1.5 % of the binary. Say it plainly to users: the registry makes the app extensible; the size win is P0. ## D13. Tests and acceptance - Pure: index parse and every rejected shape; fileset table of hostile paths (`../x`, `a/../../b`, `/abs`, `C:\x`, `a\b`, `.hidden`, `a//b`, names with a space or NUL, Windows reserved names `CON`, `NUL`, `COM1` as a whole component, duplicates differing only in case, a 65-char component, 5 components, an empty path); caps with `u64::MAX` sizes (checked add); signature canonical text; version compare; state round trip; unknown version. - IO (TempDir, fake `Transport`): happy install of each kind; hash mismatch; short body; long body; lying size; transport error mid-file; crash simulation (stop before the state flip) leaves the old state and GC removes the new dir; update keeps `previous`; rollback; remove while in use; seed install offline; index with a revoked version; app too old; invalid signature; unknown key id. - Gate and commands: all ids in `CORE_IDS`; gate classes; read-only session refuses mutating commands. - UI (kittest): rows by state; Install sends exactly one command; offline banner; permission sentences; update badge appears only after a check. - Size acceptance (measured, not promised): after P0.1 to P0.3 the macOS binary is at most 33 MB (from 40.2 MB); the registry module itself adds at most 250 KB (estimate for index, installer, validators, UI) and wiring plugins at most 1.2 MiB; both are re-measured with the commands in `measurements.md` and written into this file and `docs/ARCHITECTURE.md`. - `cargo xtask ci`, `cargo xtask i18n`, `cargo xtask layers`, `cargo xtask tree` green; no `unwrap`, `[i]`, `[a..b]`; every public item documented; files under 400 lines; at most 12 loose `.rs` per directory (`addons/` is grouped into `index/`, `install/`, `commands/` from the start). ## Open questions for the owner 1. **Ed25519 dependency**: approve `ring` as a direct dependency of `notescraft-engine` (already in the lockfile through `rustls`), or `ed25519-dalek`, or choose no signatures? Recommendation: `ring`, plus `xtask registry-sign` also using it. Blocks task 3 and every install. 2. **Sign the index too** (one extra detached signature, stops hidden `revoked` and replay of an old index)? Recommendation yes, 20 lines, after question 1. 3. **Auto-install for a vault that uses a moved item**: your words were "installed automatically if the vault uses it". This conflicts with "no network unless the user opts in". Proposal: seed files make it offline and instant, so auto-installing **from the seed** can be on by default (no network), while anything that needs the network asks. Confirm. 4. **Custom registry URL / third-party registries**: not in v1 (host allow-list is fixed to yours). Allow later with a per-registry key? 5. **Executables** (pandoc, ffmpeg, OCR engines): not downloaded in v1; they stay "detected on PATH". Confirm, or ask for a separate trust design. 6. **Ship seed files in the installer?** Costs about 0.6 MiB of installer size, buys offline migration. Alternative: network only. 7. **Release profile**: approve `strip = "symbols"` (crash backtraces lose function names; line tables are already `debug = line-tables-only` in dev only) and, after a perf run, `opt-level = "s"`. 8. **Drop the egui `default_fonts` feature** and embed only NotoEmoji (about -0.95 MiB): accept if the snapshot comparison shows no tofu in the UI chrome. 9. **Index memory**: the 6500-note vault peak of 494 MB and 220 to 380 MB steady is the real "heavy". Want a separate measurement and spec for it? 10. Naming: engine module `addons/` and user-facing "Registry" / `registry.*`: fine?