Fix note-edit append clobber and note-create double-wrap data loss
Appending to a note could replace its entire body with only the newly appended block, leaving just the frontmatter. Three compounding defects:
1. read-frontmatter mis-handled the frontmatter boundary. It paired the opening --- with the first subsequent --- line without checking the lines between were actually frontmatter. Folio notes routinely carry a nested ---...--- block in the body (note-create wraps content that itself has frontmatter -- daily notes and task briefs both do). If a note's top closing fence was ever malformed, the parser overran it, absorbed real body content (headings, whole sections) into "frontmatter", and returned a truncated body -- which the mutators then persisted. Now bail safely when a markdown heading appears before the closing fence: treat the whole document as body so nothing is destroyed.
2. note-create double-wrapped frontmatter when the supplied content itself began with a ---...--- block, producing back-to-back / nested frontmatter on disk (the malformed state defect 1 mishandles). Now lift the content's frontmatter fields up and merge them, using the remainder as the body, so a single frontmatter block is emitted.
3. note-append round-tripped through read/write-frontmatter, so any misparse became permanent loss and the frontmatter was needlessly re-encoded (which also mangled empty-list tags). Rewrite it as a raw append: keep the file byte-for-byte and bump last-updated in place. Body loss is now structurally impossible.
Also normalize tags to a list in note-create (the MCP layer passes arrays; an empty array is not null? and YAML-encoded to a malformed tags: block, which corrupted the frontmatter on the next read).
Adds test/test-append-clobber.sgl: 14 regression tests; the three boundary/clobber/double-wrap cases fail before this change and pass after.
src/folio/frontmatter.sgl | 30 +++++++++++++++++
src/folio/note.sgl | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
test/test-append-clobber.sgl | 288 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 424 insertions(+), 15 deletions(-)src/folio/frontmatter.sglmodified
(yaml-decode yaml-text))) (body (string-join (cdr rest) "\n"))) (values (or fm (dict)) body))) ;; DATA-LOSS GUARD: a markdown heading line can never be part of ;; real YAML frontmatter. If we reach one before finding the ;; closing fence, the opening `---` was NOT a frontmatter ;; delimiter (the note's closing fence is missing or malformed, ;; e.g. a note with a nested `---...---` block in its body whose ;; top fence was lost). Without this guard we would keep scanning ;; and pair the opening `---` with a `---` deeper in the body, ;; silently absorbing real body content (headings, whole ;; sections) into "frontmatter" and truncating the body -- a ;; clobber that the mutators then persist. Bail safely: treat the ;; entire document as body so nothing is destroyed. ((markdown-heading-line? (car rest)) (values (dict) content)) (else (loop (cdr rest) (cons (car rest) fm-lines))))) ;; No frontmatter (values (dict) content)))) ;; A line that, ignoring surrounding whitespace, is a Markdown ATX heading ;; (`#`, `##`, ... up to 6, followed by a space or end of line). Such a line ;; is body content, never YAML frontmatter, so its presence before a closing ;; `---` fence signals that the leading `---` was not a frontmatter opener. (define (markdown-heading-line? line) (let* ((trimmed (string-trim line)) (len (string-length trimmed))) (and (> len 0) (char=? (string-ref trimmed 0) #\#) (let count ((i 0)) (cond ((and (< i len) (< i 6) (char=? (string-ref trimmed i) #\#)) (count (+ i 1))) ;; After the run of `#`, require a space or end-of-line. ((>= i len) #t) (else (char=? (string-ref trimmed i) #\space))))))) ;;; Combine frontmatter dict and body into a complete file string. ;;; ;;; Returns a string with YAML frontmatter wrapped in `---` delimiterssrc/folio/note.sglmodified
;; Write Operations ;; ============================================================ ;; Normalize a tags-like value (Scheme list, array, #f, or stray scalar) ;; into a plain list of strings. The MCP layer hands arrays through from ;; JSON; an empty array is truthy (not `null?`) and YAML-encodes badly, so ;; folding everything to a list keeps the create/merge logic predictable. (define (as-string-list v) (cond ((not v) '()) ((null? v) '()) ((pair? v) (filter string? v)) ((array? v) (filter string? (array->list v))) (else '()))) ;;; Create a new note file. ;;; ;;; Name can include a subdirectory path like "procedures/email-triage". (substring name (+ slash 1) (string-length name)) name))) (today (format-date (current-second))) (base-fm (dict tags: tags created: today last-updated: today)) (fm (if (null? tasks) base-fm (dict-merge base-fm (dict tasks: tasks)))) (body (string-append "\n# " title "\n\n" content "\n"))) (write-file-string file-path (write-frontmatter fm body)) file-path)) ;; Tags may arrive as a Scheme list OR (from the MCP/JSON layer) as ;; an array. Normalize to a list so emptiness checks and YAML ;; encoding behave consistently (an empty array is not `null?`, and ;; encoding one emitted a malformed `tags:` block). (tags (as-string-list tags))) ;; If the supplied content ITSELF begins with a `---...---` frontmatter ;; block, do NOT wrap it again -- that produces a note with two ;; back-to-back / nested frontmatter blocks on disk, a malformed state ;; that downstream parsing and appends mishandle (the data-loss bug). ;; Instead, lift the content's frontmatter fields up and merge them into ;; the note's own frontmatter, using the remaining content as the body. (let-values (((content-fm content-body) (read-frontmatter content))) (let* ((has-leading-fm? (not (equal? content-body content))) ;; Tags: caller-provided tags win when non-empty; otherwise ;; fall back to any tags declared in the content's frontmatter. (merged-tags (if (null? tags) (as-string-list (dict-ref content-fm tags: '())) tags)) ;; Start from the content's frontmatter (preserves status/goal/ ;; custom fields), then let folio-managed fields win. (base-fm (dict-merge (if has-leading-fm? content-fm (dict)) (dict tags: merged-tags created: today last-updated: today))) (fm (if (null? tasks) base-fm (dict-merge base-fm (dict tasks: tasks)))) (body (if has-leading-fm? ;; Content carried its own frontmatter + (usually) its ;; own heading; use it verbatim as the body. (string-append "\n# " title "\n" content-body "\n") (string-append "\n# " title "\n\n" content "\n")))) (write-file-string file-path (write-frontmatter fm body)) file-path)))) ;;; Replace a note's body content, preserving frontmatter. (define (note-edit! file-path new-body) (write-frontmatter updated-fm body)))))) ;;; Append text to a note's body, preserving frontmatter. ;;; ;;; This is intentionally a RAW append: the existing file content is kept ;;; byte-for-byte and the new text is concatenated at the end, after only ;;; bumping the `last-updated` frontmatter field in place. It deliberately ;;; does NOT round-trip the note through (read-frontmatter -> write- ;;; -frontmatter), because that path can destroy data: if the parser ;;; mis-attributes body content to frontmatter (e.g. a note with a nested ;;; `---...---` block in its body whose top fence is malformed), the ;;; re-serialized body would be truncated and the whole note clobbered. ;;; Appending to the raw content makes body loss structurally impossible and ;;; also avoids re-encoding the frontmatter (which could itself mangle values ;;; such as empty lists). See `note-append!` regression tests. (define (note-append! file-path text) (: string? string? -> void?) (let ((content (read-file-string file-path))) (let-values (((fm body) (read-frontmatter content))) (let ((updated-fm (dict-merge fm (dict last-updated: (format-date (current-second)))))) (write-file-string file-path (write-frontmatter updated-fm (string-append body text))))))) (let* ((content (read-file-string file-path)) (bumped (bump-last-updated-in-text content (format-date (current-second))))) (write-file-string file-path (string-append bumped text)))) ;; Replace the `last-updated:` value inside the leading `---...---` ;; frontmatter block, returning the updated content. Only the first ;; frontmatter block is touched; everything else (including any nested ;; `---` blocks in the body) is preserved exactly. If there is no leading ;; frontmatter block, or no closing fence, the content is returned ;; unchanged -- we never restructure a malformed file. (define (bump-last-updated-in-text content today) (let ((lines (string-split content "\n"))) (if (and (pair? lines) (equal? (string-trim (car lines)) "---")) (let loop ((rest (cdr lines)) (fm-acc '()) (saw-lu #f)) (cond ;; No closing fence: malformed; leave content untouched. ((null? rest) content) ;; Closing fence: rebuild the file with the patched frontmatter. ((equal? (string-trim (car rest)) "---") (let* ((fm-lines (reverse fm-acc)) (fm-lines (if saw-lu fm-lines ;; No last-updated field present: add one. (append fm-lines (list (string-append "last-updated: " today))))) (rest-lines (cdr rest))) (string-join (append (list "---") fm-lines (list "---") rest-lines) "\n"))) ;; A last-updated line: replace its value. ((last-updated-line? (car rest)) (loop (cdr rest) (cons (string-append "last-updated: " today) fm-acc) #t)) (else (loop (cdr rest) (cons (car rest) fm-acc) saw-lu)))) ;; No frontmatter: nothing to bump, return as-is. content))) ;; Does a frontmatter line declare the `last-updated` field? (define (last-updated-line? line) (string-starts-with? (string-trim line) "last-updated:")) ;;; Apply a list of old/new string patches to a note's body. ;;;test/test-append-clobber.sgladded
;; Regression tests for the folio note-edit APPEND CLOBBER data-loss bug.;;;; Symptom: appending to a note replaced its entire body with only the newly;; appended block, leaving just the frontmatter + new section.;;;; Three compounding defects were involved:;; 1. read-frontmatter mis-handled the frontmatter boundary on notes with a;; nested `---...---` block in the body whose top fence was malformed,;; absorbing real body content into "frontmatter" and truncating the body.;; 2. note-create double-wrapped frontmatter when the supplied content itself;; began with a `---...---` block, producing the malformed on-disk state.;; 3. note-append round-tripped through read/write-frontmatter, persisting any;; misparse (and re-encoding frontmatter) -- so a parse glitch became;; permanent data loss.;;;; The fixes: harden read-frontmatter, merge leading frontmatter in note-create,;; and make note-append a raw, body-preserving append.(import (sigil test) (sigil string) (sigil dict) (sigil fs) (sigil io) (sigil path) (folio store) (folio note) (folio frontmatter));; A note whose BODY legitimately contains a nested `---...---` frontmatter-style;; block (this is what folio produces when note-create wraps content that itself;; had frontmatter -- daily notes and task briefs both look like this).(define (well-formed-nested) (string-append "---\n" "tags: []\n" "created: 2026-06-25\n" "last-updated: 2026-06-25\n" "---\n" "\n" "# 2026-06-25\n" "\n" "---\n" "last-updated: 2026-06-25\n" "---\n" "\n" "## PRIMER\n" "Keep this primer text.\n" "\n" "## Briefing\n" "Keep this briefing text.\n"));; ============================================================;; Fix 1: read-frontmatter boundary hardening;; ============================================================(test-group "read-frontmatter boundary hardening" ;; Well-formed note with a nested block: body fully preserved, frontmatter ok. (test "well-formed nested block keeps full body" (let-values (((fm body) (read-frontmatter (well-formed-nested)))) (assert-true (string-contains? body "# 2026-06-25")) (assert-true (string-contains? body "## PRIMER")) (assert-true (string-contains? body "## Briefing")) (assert-equal "2026-06-25" (dict-ref fm created: #f)))) ;; Malformed: the TOP closing fence is missing, so the first `---` the old ;; parser would find is the nested block's opener. Before the fix it absorbed ;; the `# 2026-06-25` heading + primer into "frontmatter" and truncated the ;; body. After the fix it bails on the heading and keeps everything as body. (test "missing top fence does not absorb body (no data loss)" (let ((malformed (string-append "---\n" "tags: []\n" "created: 2026-06-25\n" ;; <- no closing --- here "\n" "# 2026-06-25\n" "\n" "---\n" "last-updated: 2026-06-25\n" "---\n" "\n" "## PRIMER\n" "Keep this primer text.\n"))) (let-values (((fm body) (read-frontmatter malformed))) ;; All real content must survive in the body, nothing destroyed. (assert-true (string-contains? body "# 2026-06-25")) (assert-true (string-contains? body "## PRIMER")) (assert-true (string-contains? body "Keep this primer text."))))));; ============================================================;; Fix 3: note-append is raw and body-preserving;; ============================================================(test-group "note-append! preserves body" (test "append to nested-block note keeps all prior content" (call-with-temp-directory (lambda (dir) (let ((file (path-join dir "daily.md"))) (write-file-string file (well-formed-nested)) (note-append! file "\n## New Section\nFresh appended content.\n") (let ((after (read-file-string file))) (assert-true (string-contains? after "## PRIMER")) (assert-true (string-contains? after "## Briefing")) (assert-true (string-contains? after "Keep this primer text.")) (assert-true (string-contains? after "Fresh appended content."))))))) (test "three successive appends never clobber" (call-with-temp-directory (lambda (dir) (let ((file (path-join dir "daily.md"))) (write-file-string file (well-formed-nested)) (note-append! file "\n## A\nAlpha.\n") (note-append! file "\n## B\nBeta.\n") (note-append! file "\n## C\nGamma.\n") (let ((after (read-file-string file))) (assert-true (string-contains? after "Keep this primer text.")) (assert-true (string-contains? after "Alpha.")) (assert-true (string-contains? after "Beta.")) (assert-true (string-contains? after "Gamma."))))))) ;; Even against a malformed on-disk note (missing top fence), append must not ;; destroy existing content -- the raw append preserves every byte. (test "append to malformed note does not shrink it" (call-with-temp-directory (lambda (dir) (let* ((file (path-join dir "n.md")) (malformed (string-append "---\n" "tags: []\n" "created: 2026-06-25\n" "\n" "# Heading\n" "\n" "---\n" "k: v\n" "---\n" "\n" "## Section\n" "Important body that must survive.\n"))) (write-file-string file malformed) (note-append! file "\n## Appended\nNew line.\n") (let ((after (read-file-string file))) ;; Every original line preserved verbatim, plus the new text. (assert-true (string-contains? after "# Heading")) (assert-true (string-contains? after "## Section")) (assert-true (string-contains? after "Important body that must survive.")) (assert-true (string-contains? after "New line.")) ;; The append never makes the file smaller. (assert-true (> (string-length after) (string-length malformed)))))))) (test "append bumps last-updated in place" (call-with-temp-directory (lambda (dir) (let ((file (path-join dir "n.md"))) (write-file-string file "---\ntags: []\ncreated: 2026-01-01\nlast-updated: 2026-01-01\n---\n\n# Note\n\nBody.\n") (note-append! file "\nMore.\n") (let ((note (read-note file))) ;; created untouched, last-updated bumped to today (not 2026-01-01), ;; body fully preserved. (assert-equal "2026-01-01" (dict-ref (folio-note-frontmatter note) created: #f)) (assert-false (equal? "2026-01-01" (note-last-updated note))) (assert-true (string-contains? (folio-note-body note) "Body.")) (assert-true (string-contains? (folio-note-body note) "More."))))))) (test "append adds last-updated when frontmatter lacks it" (call-with-temp-directory (lambda (dir) (let ((file (path-join dir "n.md"))) (write-file-string file "---\ntags: []\ncreated: 2026-01-01\n---\n\n# Note\n\nBody.\n") (note-append! file "\nMore.\n") (let ((note (read-note file))) (assert-true (string? (note-last-updated note))) (assert-true (string-contains? (folio-note-body note) "Body.")) (assert-true (string-contains? (folio-note-body note) "More."))))))));; ============================================================;; Fix 2: note-create merges leading frontmatter (no double-wrap);; ============================================================(test-group "note-create! merges leading frontmatter" (test "content with its own frontmatter does not double-wrap" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (content (string-append "---\n" "tags:\n - task-brief\n - folio\n" "status: assigned\n" "---\n" "\n" "# Real Title\n" "\n" "Real body content.\n")) (file (note-create! store "tasks/example" '() content))) (let ((raw (read-file-string file))) ;; Exactly ONE frontmatter block: the file has a single `---\n...\n---` ;; opener/closer pair at the top, never two adjacent or nested-as-fm. (assert-false (string-contains? raw "---\n---\n")) ;; The note must parse cleanly: frontmatter fields lifted up. (let ((note (read-note file))) (assert-true (member "task-brief" (note-tags note))) (assert-true (member "folio" (note-tags note))) (assert-equal "assigned" (dict-ref (folio-note-frontmatter note) status: #f)) (assert-true (string? (note-created note))) ;; Body keeps the real content; the inner `---` block is gone ;; (merged), so the body has no leftover frontmatter fences. (assert-true (string-contains? (folio-note-body note) "Real body content.")))))))) (test "create-then-append on frontmatter content never clobbers" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (content "---\ntags:\n - brief\n---\n\n# Title\n\nFirst body.\n") (file (note-create! store "tasks/cascade" '() content))) (note-append! file "\n## Update One\nSecond body.\n") (note-append! file "\n## Update Two\nThird body.\n") (let ((after (read-file-string file))) (assert-true (string-contains? after "First body.")) (assert-true (string-contains? after "Second body.")) (assert-true (string-contains? after "Third body."))))))) (test "plain content (no leading frontmatter) is unchanged behavior" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (note-create! store "Plain Note" '("x") "Just text."))) (let ((note (read-note file))) (assert-true (string-contains? (folio-note-body note) "Just text.")) (assert-true (member "x" (note-tags note)))))))));; ============================================================;; Tags arrive from the MCP/JSON layer as an *array*, not a list.;; An empty array is not `null?`, and YAML-encoding it produced a malformed;; `tags:` block; it also wrongly suppressed the content's own tags.;; ============================================================(test-group "note-create! normalizes array tags" (test "empty array tags falls back to content frontmatter tags" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (content "---\ntags:\n - alpha\n - beta\n---\n\n# Title\n\nBody.\n") (file (note-create! store "tasks/arr" (list->array '()) content)) (note (read-note file)) (raw (read-file-string file))) (assert-true (member "alpha" (note-tags note))) (assert-true (member "beta" (note-tags note))) ;; And no malformed empty-list frontmatter line on disk. (assert-false (string-contains? raw "\n[]\n")))))) (test "non-empty array tags are used" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (note-create! store "tasks/arr2" (list->array '("one" "two")) "Plain body.")) (note (read-note file))) (assert-true (member "one" (note-tags note))) (assert-true (member "two" (note-tags note))))))));; ============================================================;; prepend/replace/patch sanity (must remain unaffected);; ============================================================(test-group "other mutators unaffected" (test "note-edit! (replace) still replaces body, keeps frontmatter" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (note-create! store "R" '("t") "Original."))) (note-edit! file "\n# R\n\nReplaced body.\n") (let ((note (read-note file))) (assert-true (string-contains? (folio-note-body note) "Replaced body.")) (assert-false (string-contains? (folio-note-body note) "Original.")) (assert-true (member "t" (note-tags note)))))))) (test "note-patch! still preserves frontmatter and body" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (note-create! store "P" '("a" "b") "Hello world."))) (note-patch! file (list (cons "Hello" "Hi"))) (let ((note (read-note file))) (assert-true (string-contains? (folio-note-body note) "Hi world.")) (assert-equal 2 (length (note-tags note)))))))))