Preserve task ids across every move
Moving a task renumbered it, so any id held before the move was dead after it. .claude/rules/folio-edits documented this as a fact of life and taught callers to work around it -- re-query the project after a move, and reference routed tasks by description rather than id, or project-log ids dangle.
It was never a design constraint. Ids are globally unique random hex (generate-task-id emits t- plus four hex chars); there is no per-project id space, so nothing requires a new id on move.
Two halves both had to change:
- project-add-task! called generate-task-id unconditionally and ignored any id in the metadata handed to it. It now honours a supplied id and generates one only when none is present. - move-task-in-store! and folio_inbox-triage explicitly stripped the id out of the metadata on the way in. They no longer do.
The asymmetry is what marked it as an oversight rather than a decision: project -> inbox passed metadata through untouched, and only inbox -> project destroyed the id.
But project -> inbox was not clean either. inbox-add! prepended a fresh id without removing the existing one, so the line ended up carrying TWO id entries and assoc-ref silently resolved the first. Both writers now filter any existing id and cons exactly one on, so a task can never carry two.
Covered by test/test-task-id-stability.sgl, including the round trip out and back, and the case the workaround existed for: task-update on the pre-move id still resolves after the move.
src/folio/inbox.sgl | 12 ++++++++++--
src/folio/project.sgl | 14 ++++++++++++--
src/folio/tools.sgl | 28 ++++++++++++++++++----------
test/test-task-id-stability.sgl | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 202 insertions(+), 14 deletions(-)src/folio/inbox.sglmodified
;;; ;;; The task is assigned an ID and appended to the end of the file. ;;; Returns the created task. ;;; An `id` present in `metadata` is HONOURED, so a task moved from a ;;; project back to the inbox keeps its id. Unconditionally prepending a ;;; fresh one also left the OLD id in the alist behind it, producing a line ;;; carrying two `id` entries. (define (inbox-add! store task-text metadata) (: folio-store? string? list? -> folio-task?) (inbox-ensure! store) (let* ((id (generate-task-id)) (all-meta (cons (cons 'id id) metadata)) (let* ((supplied-id (assoc-ref 'id metadata)) (id (or supplied-id (generate-task-id))) ;; Exactly one `id` entry, first -- never two. (all-meta (cons (cons 'id id) (filter (lambda (p) (not (eq? (car p) 'id))) metadata))) (task (folio-task id: id text: task-text done?: #fsrc/folio/project.sglmodified
file-path)) ;;; Add a task to a project's ## Tasks section. ;;; ;;; An `id` present in `metadata` is HONOURED. Task ids are globally unique ;;; random hex (`generate-task-id`) with no per-project id space, so nothing ;;; requires a new one on move; generating one unconditionally here is what ;;; made `folio_task-move` renumber and strand every held reference. ;;; A fresh id is generated only when none was supplied. (define (project-add-task! file-path task-text metadata) (: string? string? list? -> folio-task?) (let* ((id (generate-task-id)) (all-meta (cons (cons 'id id) metadata)) (let* ((supplied-id (assoc-ref 'id metadata)) (id (or supplied-id (generate-task-id))) ;; Exactly one `id` entry, first -- never two. (all-meta (cons (cons 'id id) (filter (lambda (p) (not (eq? (car p) 'id))) metadata))) (task (folio-task id: id text: task-text done?: #f metadata: all-meta)) (line (task->line task))src/folio/tools.sglmodified
parse-patch-entry make-tool-task-sweep make-tool-inbox-sweep make-tool-task-complete-batch) make-tool-task-complete-batch make-tool-inbox-triage make-tool-task-move move-task-in-store!) (begin ;; ============================================================ (description . "List of task IDs to mark as done (e.g., [\"t-a3f2\", \"t-b1c4\"])"))))) (required . ("ids")))) ;; ============================================================ ;; Format a task for display. (if (not task) (format "Task ~a not found in inbox." task-id) (begin ;; Strip old id from metadata so project-add-task! assigns a fresh one (let ((meta (filter (lambda (p) (not (equal? (car p) 'id))) (folio-task-metadata task)))) (project-add-task! project-file (folio-task-text task) meta)) ;; Metadata, id included, travels intact: ids are ;; globally unique, so a move never needs a new one and ;; callers holding the id stay valid afterwards. (project-add-task! project-file (folio-task-text task) (folio-task-metadata task)) (inbox-remove-by-id! store task-id) (format "Moved task ~a to project ~a." task-id project-name))))))))) found?))) ;; Move a task by ID to a target project (or inbox if #f). ;; ;; The id is preserved in every direction. Task ids are globally unique ;; random hex (`generate-task-id`) with no per-project id space, so a move ;; has never needed a new one; renumbering only ever stranded references. (define (move-task-in-store! store task-id target-project) (let ((files (cons (store-inbox-path store) (store-project-files store)))) ;; Put it back since target doesn't exist (re-add-task! (car rest) task) (format "Target project '~a' not found." target-project)) (let ((meta (filter (lambda (p) (not (equal? (car p) 'id))) (folio-task-metadata task)))) (begin (project-add-task! project-file (folio-task-text task) meta) (folio-task-metadata task)) (format "Moved task ~a to ~a." task-id target-project)))) ;; Move to inbox (begin "folio/task-complete-batch" "Mark multiple tasks as done by ID list" task-complete-batch-schema (make-tool-task-complete-batch store)) ;; Queries (mcp-server-register-tool! server "folio/tasks" "Query tasks across all projects and inbox with filters"test/test-task-id-stability.sgladded
;;; Task ids survive a move, in every direction.;;;;;; Ids are globally unique random hex (`generate-task-id`) with no per-project;;; id space, so nothing ever required a new one on move. The old behaviour was;;; an oversight in two halves: `project-add-task!` called `generate-task-id`;;; unconditionally and ignored any id handed to it, and `move-task-in-store!`;;; stripped the id out of the metadata on the way in.;;;;;; The asymmetry is what marked it as an oversight rather than a decision:;;; project -> inbox passed metadata through untouched, and only;;; inbox -> project destroyed the id. But project -> inbox was not clean;;; either: `inbox-add!` prepended a fresh id and left the old one behind it in;;; the alist, so the line ended up carrying TWO `id` entries.(import (sigil test) (sigil struct) (sigil string) (sigil dict) (sigil fs) (folio store) (folio task) (folio id) (folio inbox) (folio project) (folio tools));; How many times `key` appears in a task's metadata alist.(define (meta-key-count task key) (length (filter (lambda (p) (eq? (car p) key)) (folio-task-metadata task))))(test-group "task-move preserves ids" (test "positive control: the move lands the task in the target project" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (added (inbox-add! store "Route me" '()))) (let ((result (move-task-in-store! store (folio-task-id added) "target"))) (assert-true (string-contains? result "Moved"))) (assert-equal 0 (length (read-inbox store))) (assert-equal 1 (length (folio-project-tasks (read-project file)))))))) (test "inbox -> project keeps the id" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (added (inbox-add! store "Route me" '((priority . "high")))) (id (folio-task-id added))) (move-task-in-store! store id "target") (let ((moved (car (folio-project-tasks (read-project file))))) (assert-equal id (folio-task-id moved)) (assert-equal "high" (task-priority moved)) (assert-equal 1 (meta-key-count moved 'id))))))) (test "project -> inbox keeps the id and emits exactly one" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Source" "Goal" #f)) (added (project-add-task! file "Send me back" '((priority . "low")))) (id (folio-task-id added))) (move-task-in-store! store id #f) (let ((moved (car (read-inbox store)))) (assert-equal id (folio-task-id moved)) (assert-equal "low" (task-priority moved)) (assert-equal 1 (meta-key-count moved 'id))))))) (test "project -> project keeps the id" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (src (project-create! store "Source" "Goal" #f)) (dst (project-create! store "Dest" "Goal" #f)) (added (project-add-task! src "Relocate me" '())) (id (folio-task-id added))) (move-task-in-store! store id "dest") (let ((moved (car (folio-project-tasks (read-project dst))))) (assert-equal id (folio-task-id moved))))))) (test "a round trip out and back returns the same id" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (added (inbox-add! store "Out and back" '())) (id (folio-task-id added))) (move-task-in-store! store id "target") (move-task-in-store! store id #f) (let ((back (car (read-inbox store)))) (assert-equal id (folio-task-id back)) (assert-equal 1 (meta-key-count back 'id))))))) (test "inbox-triage keeps the id" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (added (inbox-add! store "Triage me" '())) (id (folio-task-id added)) (handler (make-tool-inbox-triage store))) (handler (dict id: id project: "target")) (let ((moved (car (folio-project-tasks (read-project file))))) (assert-equal id (folio-task-id moved)) (assert-equal 1 (meta-key-count moved 'id))))))) ;; The whole point of stable ids: a held reference stays valid. (test "task-update on the pre-move id still resolves after the move" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (added (inbox-add! store "Chase me" '())) (id (folio-task-id added))) (move-task-in-store! store id "target") (let ((result (update-task-in-store! store id (dict priority: "high")))) (assert-true (string-contains? result "Updated"))) (assert-equal "high" (task-priority (car (folio-project-tasks (read-project file))))))))))(test-group "project-add-task! id handling" (test "honours a supplied id" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (task (project-add-task! file "Preassigned" '((id . "t-abcd") (priority . "high"))))) (assert-equal "t-abcd" (folio-task-id task)) (assert-equal 1 (meta-key-count task 'id)) (let ((read-back (car (folio-project-tasks (read-project file))))) (assert-equal "t-abcd" (folio-task-id read-back)) (assert-equal "high" (task-priority read-back))))))) (test "still generates an id when none is supplied" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (task (project-add-task! file "Fresh" '()))) (assert-true (string-starts-with? (folio-task-id task) "t-")) (assert-equal 1 (meta-key-count task 'id)))))))(test-group "inbox-add! id handling" (test "honours a supplied id" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (task (inbox-add! store "Preassigned" '((id . "t-beef"))))) (assert-equal "t-beef" (folio-task-id task)) (assert-equal 1 (meta-key-count task 'id)) (assert-equal "t-beef" (folio-task-id (car (read-inbox store)))))))) (test "still generates an id when none is supplied" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (task (inbox-add! store "Fresh" '()))) (assert-true (string-starts-with? (folio-task-id task) "t-")) (assert-equal 1 (meta-key-count task 'id)))))))