--- title: "The editor" type: reference tags: [signpost, docs, reference] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:26:26.840Z" --- # The editor **Written 2026-08-26.** What the editor is built on, how an edit reaches the Markdown, the custom blocks, inline media, the MPL-2.0 rule, and what the test suite cannot see. This is the **reference** — what exists. [`../design/screens/docs.md`](../design/screens/docs.md) is the **design**: every surface, every state, every string and the keyboard path. Read that one before you change how the editor looks or what it says. Read this one before you change what it writes. --- ## 1. The ruling: BlockNote is a dependency, not the document model BlockNote was the named starting point, and one measurement decided against it: 45 fixtures load into BlockNote, serialise back, and must come back **byte-identical**. `apps/web/src/editor/document.ts` measured that before any editor code was written. The result: | Fixtures | Pass | | --- | --- | | All 45 | **7** | | Frontmatter, 23 | **0** | | Lists, 24 | **0** | **The failures are product failures, not cosmetic ones.** - A note carrying `visibility: private` comes back with no frontmatter at all, so the folder default republishes it. That is ADR 0009's whole model defeated by a serialiser. - Every `-` bullet comes back as `*`, so every observation line in a git diff shows a change the person did not make. Rule 1 says the Markdown is the ground truth and a git clone plus the bucket is everything; a round trip that rewrites bullets makes the clone unreadable as a history. None of that is a defect in BlockNote. It holds a document model and stringifies it, which is what a block editor is. It is the wrong shape for a product whose first rule is that the Markdown is the ground truth. **So `@blocknote/*` stays in `package.json`, unmodified and unimported by the note editor.** What is taken from BlockNote and from Notion is the *interaction grammar* people already know: click into text and type, `/` for a block menu, a toolbar on selection. That grammar is rebuilt over splices. ### 1.1 The MPL-2.0 rule `@blocknote/*` is MPL-2.0, which is **file-level** copyleft, not project-level. It is safe as a dependency provided nobody ever forks its files. > **Never edit a file inside `@blocknote/*`.** The rule is in `AGENTS.md`'s managed region and here. Copying a BlockNote source file into `apps/web` to change three lines is the act that would put this repository's own licence in question, and it is the tempting one, because a patched copy looks like a small change. This project is Apache-2.0 and takes no GPL, AGPL or LGPL dependency. `pnpm licence-check` names the package and the path when it refuses. ## 2. The document model > The editor never holds a document model of its own. It holds the Markdown string and a > `ParsedNote` pointing into it, and every edit is a splice of a byte range followed by a reparse. `parseNote(source)` keeps the source verbatim and returns blocks carrying `range: {start, end}` offsets into it. `renderNote` on an unmodified note returns the original bytes **because it has them**, not because the round trip happens to be lossless. That is invariant I5, and it is why the tests that matter are that ranges tile the document with no gaps and no overlaps, and that splice-then-reparse leaves untouched blocks' ids alone. **Block ids are derived, never written into the file.** `blk_` plus the first eight hex characters of `sha256(headingPath.join("/") + "\0" + normalizedText + "\0" + occurrenceIndex)`, reconciled across reparses — `packages/core/src/blocks/identity.ts`. No HTML comments in the source: Obsidian compatibility is a feature. ### 2.1 The design law every interaction obeys > **An edit writes only the region the person changed.** The chip changes a category by writing the > characters between the brackets. A cell edit writes one table line. A paragraph edit writes one > block's range. If a proposed interaction cannot say which bytes it writes, it is not designed yet. ### 2.2 The trailer rule, and why it has one implementation A block's range owns the run of line endings after it. A paragraph's raw ends `"\n\n"`, a list item's ends `"\n"`, and the last block of a file with no trailing newline ends with nothing at all. That trailing run is a property of the **slot**, not of the block, so moving a paragraph from the middle of a file to the end must not carry a blank line with it. `apps/web/src/editor/block-range.ts` holds the three splices every structural edit is built from, and it sits below both `block-actions.ts` (one block) and `sections.ts` (a heading and its subtree) precisely so the trailer rule has exactly one implementation. Rule 5: if you write a comment saying "the same rule as X", delete your copy and call X. `sections.ts` exists because of a real failure. Driving the deployed product on 2026-08-25, **Move block down** on a heading moved the heading and left its paragraphs behind. A heading and the blocks under it are one thing to a person, and they are now one thing to the splice. ## 3. What each module owns Every decision is a pure function over plain data, and the components own the wiring. That is not style — see §7. | Module | Owns | | --- | --- | | `document.ts` | The Markdown string plus its parse, and the measured BlockNote gate | | `block-range.ts` | The three splices, and the trailer rule | | `block-actions.ts` | Add, split, duplicate, move, reorder, turn into, delete — one splice each | | `sections.ts` | A heading and the blocks it owns, so a section moves as one | | `render-model.ts` | Blocks to rows for the reading view, taken from `block.raw` and never `block.text` | | `inline-tokens.ts` | Inline Markdown to spans, by asking `@signpost/core` which characters are wikilinks, tags and `kmem://` refs | | `observation.ts` | The observation chip, as a model over byte ranges | | `properties.ts` | Typed frontmatter — six fields kmem understands, the rest passed through unchanged | | `insert-menu.ts` | The slash menu: which kinds exist, what Markdown each is, what matches, what a key does | | `shortcuts.ts` | The source keymap, as `{value, start, end}` in and the same shape out | | `media.ts` | Inserting an image and applying its alt text later | | `collab/` | The socket, the CRDT, the offline cache and presence. Live editing, §7 | **`render-model.ts` reads `raw`, not `text`, and that distinction is load-bearing.** `block.text` is the normalised rendering — a heading with no hashes, whitespace collapsed, inline syntax resolved — which is exactly right for hashing, chunking, embedding and BM25, and exactly wrong for display. By the time `**strong**` has become `strong` there is nothing left to draw in bold. **`inline-tokens.ts` delegates and does not decide.** `` `#tag` `` inside a code span is a literal. `#F6F7F4` is a colour. `#2026` is a year. `[[folder/Target#heading|alias]]` has three parts. Every one of those rules already lives in `packages/core/src/inline/`, is fuzzed there, and decides what goes in the `tags` column — so a second opinion in the web application would be a second implementation of the same concept. ## 4. The three modes | Mode | What it is | | --- | --- | | **Read** | The default, and most of every visit. The rendered note with the status gutter beside every block | | **Edit** | Writing without seeing Markdown. The interaction grammar, over splices | | **Source** | The Markdown on the left, the note as it will read on the right | **The source-mode preview is not a preview.** It is `NoteBody` — the same component the reading view uses — run over the same parse the save will send. Type `- [decision] x #tag` and the chip appears, because the parser recognised an observation, not because `SourceMode.tsx` has a rule about brackets. Below 900 px the two panes stack and a toggle picks one. ## 5. The custom blocks ### 5.1 The observation chip This is the hardest design problem in the product: `- [decision] content #tag` is obvious in Markdown and non-obvious in a visual editor. The chip is a category dropdown, a body and tag chips, laid out as one flowing inline line. **It edits ranges rather than re-rendering the line**, and that is the whole promise. `renderObservation` in `@signpost/core` writes a *canonical* line — a `- ` bullet, a lower-case category, single spaces — which is right when the product authors an observation and wrong when a person edits one somebody else wrote. Changing the category writes the characters between the brackets and nothing else, so a person's own spacing, their `*` bullet and their trailing comment all survive. ### 5.2 The properties panel The note's YAML header as a list of typed fields. Six of them kmem understands; every other key is passed through unchanged and un-reinterpreted. **`visibility` is a field here because it is a field in the file.** ADR 0009 §2: a note's permission level lives in its Markdown, because a permission that lives only in D1 means a rebuild from R2 silently republishes every private note. So the panel does not call a permissions endpoint. It reads `visibility:` and `access:` out of the header it is already showing. Share is the one control that changes them (ADR 0026), and it writes them with `setFrontmatterProperties` — the same splice as everything else. `publication:` is read from the same header by `routes/public/publication.ts`, which is how a note reaches the web with no schema change ([`ADR/0015`](./ADR/0015-two-documentation-organizations.md), [`TAXONOMY.md`](./TAXONOMY.md) §9). ### 5.3 The status gutter A 16×16 glyph beside every block, encoding author kind, verification status and continuous freshness at once. [`../design/DESIGN.md`](../design/DESIGN.md) defines provenance and status, and forbids decorative use. `apps/worker/src/routes/api/waymark.ts` computes the glyph from the same `freshnessDecay` scores retrieval uses, so the glyph beside a block and the ranking of that block cannot disagree. `waymark` is the wire field's frozen name. ## 6. Inline media Added 2026-08-26. An image in a note is `![alt](url)` and nothing else — no container, no id in the file, no side table, no alt text in R2 metadata. ### 6.1 The upload path ``` POST /api/media { permalink, filename, content_base64 } requireNote a note you cannot read gives the note's own 404 sniffMediaType the type comes from the bytes, never from the claim storedFilename one safe segment, extension forced to the sniffed type assetIdFor ast_ + 16 hex of sha256(noteId ‖ NUL ‖ contentSha) commitAsset a WriteToken, the put, an audit row describeImage the vision call, bounded recordSpend one ledger row → { asset_id, url, markdown, alt, alt_source, neurons, usd, … } ``` The physical key is `tenantScopedKey(ctx, "assets/{id}/{file}")`, the same one rule every other R2 object goes through ([`TENANCY.md`](./TENANCY.md) §3.2). **There is no new write path.** `putAsset` demands a `WriteToken` and only `CommitService` holds the mint, so `commitAsset` lives beside `commitSessionTokenChange` on that file's own argument: invariant I1 is about the mint, not about the shape of the payload. `single-write-path.test.ts` passes with no new allowlist entry. **The endpoint changes no note.** It returns `![alt](url)`; inserting that is an ordinary splice through `CommitService.commit`, with its own blob, provenance row, audit row and index job. Two mutations, because two things happened. ### 6.2 Alt text, and how it reaches `recall` `@cf/mistralai/mistral-small-3.1-24b-instruct`, measured against the live account on 2026-08-26 at **about 60 neurons and $0.00066 per image**, 1.8 to 3.1 seconds. File size predicts nothing: the model resizes to its own tile grid, so a 313 KB screenshot cost less than a 118 KB one. One `recall` is 1.759 neurons, so a caption is about 35 recalls, and the plan's 10,000 included neurons a day is about 165 captions. The cost arrives under `usage.neurons`, the reranker's shape, so `inference-cost.ts` reads it with no change. **The alt text is not bolted on to retrieval.** `markdown/text.ts` builds `Block.text` with `mdast-util-to-string`, which includes an image's alt by default, so the sentence is already in the block text that is hashed into the block id, packed by `chunkNote`, embedded and matched by BM25. Nothing in `packages/core/src/chunk/` changed. A `recall` hit on an image is an ordinary claim whose text is the alt sentence under its heading path, citing `kmem://backend/auth-decision#blk_xxxxxxxx`. **When the model is slow or fails, nothing is invented.** `ALT_TEXT_DEADLINE_MS` is 8,000 — three times the slowest measurement, a bound on a person's patience rather than a guess about the model. Past it, alt text comes from the filename; when the filename says nothing either (`IMG_4821.png`, a timestamp) the alt text is `""` and the person gets `![](url)` to fill in. A raced-out call is still billed and its cost is unknown, so it records one call, `reported: 0`, `usdSource: "none"`, `cost_reported: false`. `POST /api/media/{assetId}/describe` is the retry door. Escaping happens **once**, in `packages/core`. It happened twice for one commit — `cleanAltText` escaped and `imageMarkdown` escaped again, so `[Q3]` would have reached a customer's file as `\[Q3\]` — and there is now a test at both the service and the route level. ### 6.3 What is refused, and why | Case | Answer | | --- | --- | | Larger than 10 MiB | Refused twice: a Zod bound on the base64 string before decode, and a decoded-length check. A memory limit, not a storage one — base64 in JSON means the isolate briefly holds three copies | | Not an image | 415, naming the format it recognised. "PDF" is actionable; "not an image" about a real image is not | | `payload.png` containing HTML | Refused as HTML. The filename is never consulted | | `payload.html` containing a PNG | Stored as `payload.png`. The served `Content-Type` is the sniffed one, plus `X-Content-Type-Options: nosniff` | | SVG | Refused outright. It is a document that can carry `