Commitf765fe9aRecorded2 Aug 2026Repositoryfolio

Preserve task ids across every move

Message

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.

Changed
 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(-)
Diff
src/folio/inbox.sglmodified
@@ -64,11 +64,19 @@
64
;;;
65
;;; The task is assigned an ID and appended to the end of the file.
66
;;; Returns the created task.
+67
;;; An `id` present in `metadata` is HONOURED, so a task moved from a
+68
;;; project back to the inbox keeps its id. Unconditionally prepending a
+69
;;; fresh one also left the OLD id in the alist behind it, producing a line
+70
;;; carrying two `id` entries.
71
(define (inbox-add! store task-text metadata)
72
(: folio-store? string? list? -> folio-task?)
73
(inbox-ensure! store)
70
(let* ((id (generate-task-id))
71
(all-meta (cons (cons 'id id) metadata))
+74
(let* ((supplied-id (assoc-ref 'id metadata))
+75
(id (or supplied-id (generate-task-id)))
+76
;; Exactly one `id` entry, first -- never two.
+77
(all-meta (cons (cons 'id id)
+78
(filter (lambda (p) (not (eq? (car p) 'id)))
+79
metadata)))
80
(task (folio-task id: id
81
text: task-text
82
done?: #f
src/folio/project.sglmodified
@@ -256,10 +256,20 @@
256
file-path))
257
258
;;; Add a task to a project's ## Tasks section.
+259
;;;
+260
;;; An `id` present in `metadata` is HONOURED. Task ids are globally unique
+261
;;; random hex (`generate-task-id`) with no per-project id space, so nothing
+262
;;; requires a new one on move; generating one unconditionally here is what
+263
;;; made `folio_task-move` renumber and strand every held reference.
+264
;;; A fresh id is generated only when none was supplied.
265
(define (project-add-task! file-path task-text metadata)
266
(: string? string? list? -> folio-task?)
261
(let* ((id (generate-task-id))
262
(all-meta (cons (cons 'id id) metadata))
+267
(let* ((supplied-id (assoc-ref 'id metadata))
+268
(id (or supplied-id (generate-task-id)))
+269
;; Exactly one `id` entry, first -- never two.
+270
(all-meta (cons (cons 'id id)
+271
(filter (lambda (p) (not (eq? (car p) 'id)))
+272
metadata)))
273
(task (folio-task id: id text: task-text
274
done?: #f metadata: all-meta))
275
(line (task->line task))
src/folio/tools.sglmodified
@@ -29,7 +29,10 @@
29
parse-patch-entry
30
make-tool-task-sweep
31
make-tool-inbox-sweep
32
make-tool-task-complete-batch)
+32
make-tool-task-complete-batch
+33
make-tool-inbox-triage
+34
make-tool-task-move
+35
move-task-in-store!)
36
(begin
37
38
;; ============================================================
@@ -271,6 +274,7 @@
274
(description . "List of task IDs to mark as done (e.g., [\"t-a3f2\", \"t-b1c4\"])")))))
275
(required . ("ids"))))
276
+277
278
;; ============================================================
279
280
;; Format a task for display.
@@ -370,12 +374,12 @@
374
(if (not task)
375
(format "Task ~a not found in inbox." task-id)
376
(begin
373
;; Strip old id from metadata so project-add-task! assigns a fresh one
374
(let ((meta (filter (lambda (p) (not (equal? (car p) 'id)))
375
(folio-task-metadata task))))
376
(project-add-task! project-file
377
(folio-task-text task)
378
meta))
+377
;; Metadata, id included, travels intact: ids are
+378
;; globally unique, so a move never needs a new one and
+379
;; callers holding the id stay valid afterwards.
+380
(project-add-task! project-file
+381
(folio-task-text task)
+382
(folio-task-metadata task))
383
(inbox-remove-by-id! store task-id)
384
(format "Moved task ~a to project ~a."
385
task-id project-name)))))))))
@@ -1324,6 +1328,10 @@
1328
found?)))
1329
1330
;; Move a task by ID to a target project (or inbox if #f).
+1331
;;
+1332
;; The id is preserved in every direction. Task ids are globally unique
+1333
;; random hex (`generate-task-id`) with no per-project id space, so a move
+1334
;; has never needed a new one; renumbering only ever stranded references.
1335
(define (move-task-in-store! store task-id target-project)
1336
(let ((files (cons (store-inbox-path store)
1337
(store-project-files store))))
@@ -1341,11 +1349,10 @@
1349
;; Put it back since target doesn't exist
1350
(re-add-task! (car rest) task)
1351
(format "Target project '~a' not found." target-project))
1344
(let ((meta (filter (lambda (p) (not (equal? (car p) 'id)))
1345
(folio-task-metadata task))))
+1352
(begin
1353
(project-add-task! project-file
1354
(folio-task-text task)
1348
meta)
+1355
(folio-task-metadata task))
1356
(format "Moved task ~a to ~a." task-id target-project))))
1357
;; Move to inbox
1358
(begin
@@ -1535,6 +1542,7 @@
1542
"folio/task-complete-batch" "Mark multiple tasks as done by ID list"
1543
task-complete-batch-schema (make-tool-task-complete-batch store))
1544
+1545
1546
;; Queries
1547
(mcp-server-register-tool! server
1548
"folio/tasks" "Query tasks across all projects and inbox with filters"
test/test-task-id-stability.sgladded
@@ -0,0 +1,162 @@
+1
;;; Task ids survive a move, in every direction.
+2
;;;
+3
;;; Ids are globally unique random hex (`generate-task-id`) with no per-project
+4
;;; id space, so nothing ever required a new one on move. The old behaviour was
+5
;;; an oversight in two halves: `project-add-task!` called `generate-task-id`
+6
;;; unconditionally and ignored any id handed to it, and `move-task-in-store!`
+7
;;; stripped the id out of the metadata on the way in.
+8
;;;
+9
;;; The asymmetry is what marked it as an oversight rather than a decision:
+10
;;; project -> inbox passed metadata through untouched, and only
+11
;;; inbox -> project destroyed the id. But project -> inbox was not clean
+12
;;; either: `inbox-add!` prepended a fresh id and left the old one behind it in
+13
;;; the alist, so the line ended up carrying TWO `id` entries.
+14
+15
(import (sigil test)
+16
(sigil struct)
+17
(sigil string)
+18
(sigil dict)
+19
(sigil fs)
+20
(folio store)
+21
(folio task)
+22
(folio id)
+23
(folio inbox)
+24
(folio project)
+25
(folio tools))
+26
+27
;; How many times `key` appears in a task's metadata alist.
+28
(define (meta-key-count task key)
+29
(length (filter (lambda (p) (eq? (car p) key))
+30
(folio-task-metadata task))))
+31
+32
(test-group "task-move preserves ids"
+33
(test "positive control: the move lands the task in the target project"
+34
(call-with-temp-directory
+35
(lambda (dir)
+36
(let* ((store (make-folio-store dir))
+37
(file (project-create! store "Target" "Goal" #f))
+38
(added (inbox-add! store "Route me" '())))
+39
(let ((result (move-task-in-store! store (folio-task-id added) "target")))
+40
(assert-true (string-contains? result "Moved")))
+41
(assert-equal 0 (length (read-inbox store)))
+42
(assert-equal 1 (length (folio-project-tasks (read-project file))))))))
+43
+44
(test "inbox -> project keeps the id"
+45
(call-with-temp-directory
+46
(lambda (dir)
+47
(let* ((store (make-folio-store dir))
+48
(file (project-create! store "Target" "Goal" #f))
+49
(added (inbox-add! store "Route me" '((priority . "high"))))
+50
(id (folio-task-id added)))
+51
(move-task-in-store! store id "target")
+52
(let ((moved (car (folio-project-tasks (read-project file)))))
+53
(assert-equal id (folio-task-id moved))
+54
(assert-equal "high" (task-priority moved))
+55
(assert-equal 1 (meta-key-count moved 'id)))))))
+56
+57
(test "project -> inbox keeps the id and emits exactly one"
+58
(call-with-temp-directory
+59
(lambda (dir)
+60
(let* ((store (make-folio-store dir))
+61
(file (project-create! store "Source" "Goal" #f))
+62
(added (project-add-task! file "Send me back" '((priority . "low"))))
+63
(id (folio-task-id added)))
+64
(move-task-in-store! store id #f)
+65
(let ((moved (car (read-inbox store))))
+66
(assert-equal id (folio-task-id moved))
+67
(assert-equal "low" (task-priority moved))
+68
(assert-equal 1 (meta-key-count moved 'id)))))))
+69
+70
(test "project -> project keeps the id"
+71
(call-with-temp-directory
+72
(lambda (dir)
+73
(let* ((store (make-folio-store dir))
+74
(src (project-create! store "Source" "Goal" #f))
+75
(dst (project-create! store "Dest" "Goal" #f))
+76
(added (project-add-task! src "Relocate me" '()))
+77
(id (folio-task-id added)))
+78
(move-task-in-store! store id "dest")
+79
(let ((moved (car (folio-project-tasks (read-project dst)))))
+80
(assert-equal id (folio-task-id moved)))))))
+81
+82
(test "a round trip out and back returns the same id"
+83
(call-with-temp-directory
+84
(lambda (dir)
+85
(let* ((store (make-folio-store dir))
+86
(file (project-create! store "Target" "Goal" #f))
+87
(added (inbox-add! store "Out and back" '()))
+88
(id (folio-task-id added)))
+89
(move-task-in-store! store id "target")
+90
(move-task-in-store! store id #f)
+91
(let ((back (car (read-inbox store))))
+92
(assert-equal id (folio-task-id back))
+93
(assert-equal 1 (meta-key-count back 'id)))))))
+94
+95
(test "inbox-triage keeps the id"
+96
(call-with-temp-directory
+97
(lambda (dir)
+98
(let* ((store (make-folio-store dir))
+99
(file (project-create! store "Target" "Goal" #f))
+100
(added (inbox-add! store "Triage me" '()))
+101
(id (folio-task-id added))
+102
(handler (make-tool-inbox-triage store)))
+103
(handler (dict id: id project: "target"))
+104
(let ((moved (car (folio-project-tasks (read-project file)))))
+105
(assert-equal id (folio-task-id moved))
+106
(assert-equal 1 (meta-key-count moved 'id)))))))
+107
+108
;; The whole point of stable ids: a held reference stays valid.
+109
(test "task-update on the pre-move id still resolves after the move"
+110
(call-with-temp-directory
+111
(lambda (dir)
+112
(let* ((store (make-folio-store dir))
+113
(file (project-create! store "Target" "Goal" #f))
+114
(added (inbox-add! store "Chase me" '()))
+115
(id (folio-task-id added)))
+116
(move-task-in-store! store id "target")
+117
(let ((result (update-task-in-store! store id (dict priority: "high"))))
+118
(assert-true (string-contains? result "Updated")))
+119
(assert-equal "high"
+120
(task-priority
+121
(car (folio-project-tasks (read-project file))))))))))
+122
+123
(test-group "project-add-task! id handling"
+124
(test "honours a supplied id"
+125
(call-with-temp-directory
+126
(lambda (dir)
+127
(let* ((store (make-folio-store dir))
+128
(file (project-create! store "Target" "Goal" #f))
+129
(task (project-add-task! file "Preassigned"
+130
'((id . "t-abcd") (priority . "high")))))
+131
(assert-equal "t-abcd" (folio-task-id task))
+132
(assert-equal 1 (meta-key-count task 'id))
+133
(let ((read-back (car (folio-project-tasks (read-project file)))))
+134
(assert-equal "t-abcd" (folio-task-id read-back))
+135
(assert-equal "high" (task-priority read-back)))))))
+136
+137
(test "still generates an id when none is supplied"
+138
(call-with-temp-directory
+139
(lambda (dir)
+140
(let* ((store (make-folio-store dir))
+141
(file (project-create! store "Target" "Goal" #f))
+142
(task (project-add-task! file "Fresh" '())))
+143
(assert-true (string-starts-with? (folio-task-id task) "t-"))
+144
(assert-equal 1 (meta-key-count task 'id)))))))
+145
+146
(test-group "inbox-add! id handling"
+147
(test "honours a supplied id"
+148
(call-with-temp-directory
+149
(lambda (dir)
+150
(let* ((store (make-folio-store dir))
+151
(task (inbox-add! store "Preassigned" '((id . "t-beef")))))
+152
(assert-equal "t-beef" (folio-task-id task))
+153
(assert-equal 1 (meta-key-count task 'id))
+154
(assert-equal "t-beef" (folio-task-id (car (read-inbox store))))))))
+155
+156
(test "still generates an id when none is supplied"
+157
(call-with-temp-directory
+158
(lambda (dir)
+159
(let* ((store (make-folio-store dir))
+160
(task (inbox-add! store "Fresh" '())))
+161
(assert-true (string-starts-with? (folio-task-id task) "t-"))
+162
(assert-equal 1 (meta-key-count task 'id)))))))