Commit2f2ea120Recorded2 Aug 2026Repositoryfolio

Refuse id-addressed mutations when the id is ambiguous

Message

Task ids are four random hex with NO uniqueness check at any scope -- generate-task-id takes no store argument, so it structurally cannot check. Collisions are therefore routine at estate scale rather than exotic: 1291 task lines over a 65536 space predicts ~12 colliding ids, and 14 were found in the real folio. That arithmetic, not a bulk-creation defect, is the whole root cause of generation.

Until now every id-addressed mutation picked whichever task the search reached first and returned a success message naming the id, which reads as confirmation. On 2026-08-02 that completed a live task in sigil-ecosystem when a disposable probe was minted the same id.

complete, reopen, update and move now refuse when an id matches more than one task, and name every match with its file and description so the caller can tell them apart. The batch tools inherit it per id: an ambiguous id is refused while the unambiguous ones in the same call still run.

The matcher scans every line of every file the mutators can reach, INCLUDING ## Log, where project-clean! archives completed copies that the mutators can still resolve. That alignment is deliberate and the matcher does not claim independence from their traversal -- what it does not share is the EARLY EXIT, and the early exit is the entire defect, because it is what turns two matches into a silent choice.

Sabotage-tested in both directions: a guard that never refuses reddens all seven refusal tests while the four positive controls stay green; a guard that always refuses reddens the four positive controls. Neither failure mode is quiet.

Changed
 src/folio/tools.sgl        |  90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
 test/test-id-ambiguity.sgl | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 281 insertions(+), 8 deletions(-)
Diff
src/folio/tools.sglmodified
@@ -1279,16 +1279,87 @@
1279
;; Task Operations
1280
;; ============================================================
1281
+1282
;; ============================================================
+1283
;; Ambiguous id refusal
+1284
;; ============================================================
+1285
+1286
;; Every task line matching `task-id` in one file, as (file . task) pairs.
+1287
;;
+1288
;; Scans every line, exactly as the mutators do -- including `## Log`,
+1289
;; where `project-clean!` archives completed copies. That alignment is
+1290
;; deliberate: this counter must cover precisely what a mutation can
+1291
;; REACH, or it reports "1 match" for an id a mutation can still resolve
+1292
;; two ways.
+1293
;;
+1294
;; It is therefore NOT independent of the mutators' traversal and does not
+1295
;; claim to be. What it does not share is the EARLY EXIT -- and the early
+1296
;; exit is the entire defect, because it is what turns two matches into a
+1297
;; silent choice.
+1298
(define (matches-in-file file-path task-id)
+1299
(if (not (file-exists? file-path))
+1300
'()
+1301
(let loop ((rest (string-split (read-file-string file-path) "\n"))
+1302
(acc '()))
+1303
(if (null? rest)
+1304
(reverse acc)
+1305
(let ((task (if (task-line? (car rest))
+1306
(parse-task-line (car rest))
+1307
#f)))
+1308
(loop (cdr rest)
+1309
(if (and task (equal? (folio-task-id task) task-id))
+1310
(cons (cons file-path task) acc)
+1311
acc)))))))
+1312
+1313
;;; Every task matching `task-id` across the files a mutation can reach.
+1314
(define (find-all-tasks-by-id store task-id)
+1315
(: folio-store? string? -> list?)
+1316
(apply append
+1317
(map (lambda (f) (matches-in-file f task-id))
+1318
(cons (store-inbox-path store)
+1319
(store-project-files store)))))
+1320
+1321
;; Refuse an id-addressed mutation when the id resolves to more than one
+1322
;; task. Returns #f when it is safe to proceed, or a refusal message.
+1323
;;
+1324
;; Task ids are four random hex with NO uniqueness check anywhere, so
+1325
;; collisions are routine at estate scale rather than exotic: 1291 task
+1326
;; lines over a 65536 space predicts ~12 colliding ids, and 14 were found.
+1327
;;
+1328
;; Picking one silently and returning a success message naming the id is
+1329
;; how a disposable probe's cleanup completed a live task in another
+1330
;; project on 2026-08-02. Worse, the choice is not even stable: the file
+1331
;; list is unsorted `directory-list` order, so which task wins depends on
+1332
;; readdir, and any move can change it by removing the copy that was
+1333
;; winning.
+1334
(define (refuse-if-ambiguous store task-id verb)
+1335
(let ((matches (find-all-tasks-by-id store task-id)))
+1336
(if (< (length matches) 2)
+1337
#f
+1338
(string-join
+1339
(append
+1340
(list (format "REFUSING to ~a ~a -- it matches ~a tasks and folio cannot tell which you mean:"
+1341
verb task-id (length matches)))
+1342
(map (lambda (m)
+1343
(format " ~a [~a] ~a"
+1344
(path-basename (car m))
+1345
(if (folio-task-done? (cdr m)) "x" " ")
+1346
(folio-task-text (cdr m))))
+1347
matches)
+1348
(list ""
+1349
"Task ids in this estate are not unique. Give one of them a new id to disambiguate, then retry."))
+1350
"\n"))))
+1351
1352
;; Complete a task by ID, searching across inbox and all project files.
1353
(define (complete-task-in-store! store task-id)
1284
(let ((files (cons (store-inbox-path store)
+1354
(or (refuse-if-ambiguous store task-id "complete")
+1355
(let ((files (cons (store-inbox-path store)
1356
(store-project-files store))))
1357
(let loop ((rest files))
1358
(if (null? rest)
1359
(format "Task ~a not found." task-id)
1360
(if (complete-task-in-file! (car rest) task-id)
1361
(format "Completed task ~a." task-id)
1291
(loop (cdr rest)))))))
+1362
(loop (cdr rest))))))))
1363
1364
;; Try to complete a task in a specific file. Returns #t if found.
1365
(define (complete-task-in-file! file-path task-id)
@@ -1323,14 +1394,15 @@
1394
1395
;; Reopen a completed task by ID.
1396
(define (uncomplete-task-in-store! store task-id)
1326
(let ((files (cons (store-inbox-path store)
+1397
(or (refuse-if-ambiguous store task-id "reopen")
+1398
(let ((files (cons (store-inbox-path store)
1399
(store-project-files store))))
1400
(let loop ((rest files))
1401
(if (null? rest)
1402
(format "Task ~a not found." task-id)
1403
(if (uncomplete-task-in-file! (car rest) task-id)
1404
(format "Reopened task ~a." task-id)
1333
(loop (cdr rest)))))))
+1405
(loop (cdr rest))))))))
1406
1407
;; Try to uncomplete a task in a specific file. Returns #t if found.
1408
(define (uncomplete-task-in-file! file-path task-id)
@@ -1363,14 +1435,15 @@
1435
1436
;; Update task metadata by ID.
1437
(define (update-task-in-store! store task-id args)
1366
(let ((files (cons (store-inbox-path store)
+1438
(or (refuse-if-ambiguous store task-id "update")
+1439
(let ((files (cons (store-inbox-path store)
1440
(store-project-files store))))
1441
(let loop ((rest files))
1442
(if (null? rest)
1443
(format "Task ~a not found." task-id)
1444
(if (update-task-in-file! (car rest) task-id args)
1445
(format "Updated task ~a." task-id)
1373
(loop (cdr rest)))))))
+1446
(loop (cdr rest))))))))
1447
1448
;; Try to update a task's metadata and/or text in a specific file.
1449
(define (update-task-in-file! file-path task-id args)
@@ -1407,7 +1480,8 @@
1480
;; random hex (`generate-task-id`) with no per-project id space, so a move
1481
;; has never needed a new one; renumbering only ever stranded references.
1482
(define (move-task-in-store! store task-id target-project)
1410
(let ((files (cons (store-inbox-path store)
+1483
(or (refuse-if-ambiguous store task-id "move")
+1484
(let ((files (cons (store-inbox-path store)
1485
(store-project-files store))))
1486
;; Find the task in any file
1487
(let loop ((rest files))
@@ -1433,7 +1507,7 @@
1507
(inbox-add! store (folio-task-text task)
1508
(folio-task-metadata task))
1509
(format "Moved task ~a to inbox." task-id)))
1436
(loop (cdr rest))))))))
+1510
(loop (cdr rest)))))))))
1511
1512
;; Find a task by ID in a file, remove it, return the task (or #f).
1513
(define (find-and-remove-task! file-path task-id)
test/test-id-ambiguity.sgladded
@@ -0,0 +1,199 @@
+1
;;; An id-addressed mutation must refuse when the id is ambiguous.
+2
;;;
+3
;;; Task ids are four random hex with NO uniqueness check at any scope, so
+4
;;; collisions are routine at estate scale, not exotic: 1291 task lines over a
+5
;;; 65536 space predicts ~12 colliding ids, and 14 were found in the real
+6
;;; folio on 2026-08-02. One of them cost a live task: a disposable probe was
+7
;;; minted `t-de62`, which already belonged to an open task in another
+8
;;; project, and the probe's cleanup completed the REAL one.
+9
;;;
+10
;;; The old behaviour picked whichever task the search reached first and
+11
;;; returned a success message naming the id, which reads as confirmation.
+12
+13
(import (sigil test)
+14
(sigil struct)
+15
(sigil string)
+16
(sigil dict)
+17
(sigil fs)
+18
(sigil path)
+19
(folio store)
+20
(folio task)
+21
(folio inbox)
+22
(folio project)
+23
(folio tools))
+24
+25
(define (mkproject dir name body)
+26
(write-file-string
+27
(path-join (path-join dir "projects") (string-append name ".md"))
+28
(string-append
+29
"---\nstatus: active\ngoal: g\narea: general\nlast-updated: 2026-08-02\n---\n\n"
+30
"# " name "\n\n## Tasks\n" body "\n## Log\n")))
+31
+32
(define (proj dir name)
+33
(read-project (path-join (path-join dir "projects") (string-append name ".md"))))
+34
+35
;; ============================================================
+36
;; Positive controls -- a UNIQUE id must still work everywhere.
+37
;; A guard that refuses everything is as useless as one that refuses nothing.
+38
;; ============================================================
+39
+40
(test-group "unique ids are unaffected"
+41
(test "complete works on a unique id"
+42
(call-with-temp-directory
+43
(lambda (dir)
+44
(let* ((store (make-folio-store dir))
+45
(t (inbox-add! store "only one" '())))
+46
(assert-true (string-contains?
+47
(complete-task-in-store! store (folio-task-id t))
+48
"Completed"))
+49
(assert-true (folio-task-done? (car (read-inbox store))))))))
+50
+51
(test "update works on a unique id"
+52
(call-with-temp-directory
+53
(lambda (dir)
+54
(let* ((store (make-folio-store dir))
+55
(t (inbox-add! store "only one" '())))
+56
(assert-true (string-contains?
+57
(update-task-in-store! store (folio-task-id t)
+58
(dict priority: "high"))
+59
"Updated"))
+60
(assert-equal "high" (task-priority (car (read-inbox store))))))))
+61
+62
(test "move works on a unique id"
+63
(call-with-temp-directory
+64
(lambda (dir)
+65
(let ((store (make-folio-store dir)))
+66
(mkproject dir "target" "")
+67
(let ((t (inbox-add! store "only one" '())))
+68
(assert-true (string-contains?
+69
(move-task-in-store! store (folio-task-id t) "target")
+70
"Moved"))
+71
(assert-equal 1 (length (folio-project-tasks (proj dir "target")))))))))
+72
+73
(test "reopen works on a unique id"
+74
(call-with-temp-directory
+75
(lambda (dir)
+76
(let* ((store (make-folio-store dir))
+77
(t (inbox-add! store "only one" '())))
+78
(complete-task-in-store! store (folio-task-id t))
+79
(assert-true (string-contains?
+80
(uncomplete-task-in-store! store (folio-task-id t))
+81
"Reopened"))
+82
(assert-false (folio-task-done? (car (read-inbox store)))))))))
+83
+84
;; ============================================================
+85
;; The refusal
+86
;; ============================================================
+87
+88
(test-group "ambiguous ids are refused, not guessed"
+89
(test "complete refuses and names both tasks"
+90
(call-with-temp-directory
+91
(lambda (dir)
+92
(let ((store (make-folio-store dir)))
+93
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")
+94
(write-file-string (store-inbox-path store)
+95
"# Inbox\n- [ ] disposable probe item {id: t-dead}\n")
+96
(let ((result (complete-task-in-store! store "t-dead")))
+97
(assert-true (string-contains? result "REFUSING"))
+98
(assert-true (string-contains? result "matches 2 tasks"))
+99
;; both descriptions present, so the caller can tell them apart
+100
(assert-true (string-contains? result "REAL live task"))
+101
(assert-true (string-contains? result "disposable probe item")))
+102
;; and NOTHING was mutated
+103
(assert-false (folio-task-done? (car (read-inbox store))))
+104
(assert-false (folio-task-done?
+105
(car (folio-project-tasks (proj dir "real")))))))))
+106
+107
(test "update refuses and mutates nothing"
+108
(call-with-temp-directory
+109
(lambda (dir)
+110
(let ((store (make-folio-store dir)))
+111
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")
+112
(write-file-string (store-inbox-path store)
+113
"# Inbox\n- [ ] probe item {id: t-dead}\n")
+114
(assert-true (string-contains?
+115
(update-task-in-store! store "t-dead" (dict priority: "high"))
+116
"REFUSING"))
+117
(assert-false (task-priority (car (read-inbox store))))
+118
(assert-false (task-priority
+119
(car (folio-project-tasks (proj dir "real")))))))))
+120
+121
(test "move refuses and mutates nothing"
+122
(call-with-temp-directory
+123
(lambda (dir)
+124
(let ((store (make-folio-store dir)))
+125
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")
+126
(mkproject dir "target" "")
+127
(write-file-string (store-inbox-path store)
+128
"# Inbox\n- [ ] probe item {id: t-dead}\n")
+129
(assert-true (string-contains?
+130
(move-task-in-store! store "t-dead" "target")
+131
"REFUSING"))
+132
(assert-equal 1 (length (read-inbox store)))
+133
(assert-equal 0 (length (folio-project-tasks (proj dir "target"))))
+134
(assert-equal 1 (length (folio-project-tasks (proj dir "real"))))))))
+135
+136
(test "reopen refuses"
+137
(call-with-temp-directory
+138
(lambda (dir)
+139
(let ((store (make-folio-store dir)))
+140
(mkproject dir "real" "- [x] REAL done task {id: t-dead}\n")
+141
(write-file-string (store-inbox-path store)
+142
"# Inbox\n- [x] probe item {id: t-dead}\n")
+143
(assert-true (string-contains?
+144
(uncomplete-task-in-store! store "t-dead")
+145
"REFUSING"))))))
+146
+147
;; This is the incident: cleanup ran a BATCH, which is where it did damage.
+148
(test "task-complete-batch refuses the ambiguous id and still does the others"
+149
(call-with-temp-directory
+150
(lambda (dir)
+151
(let ((store (make-folio-store dir)))
+152
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")
+153
(write-file-string (store-inbox-path store)
+154
(string-append
+155
"# Inbox\n"
+156
"- [ ] probe item {id: t-dead}\n"
+157
"- [ ] unrelated safe item {id: t-5afe}\n"))
+158
(let ((result ((make-tool-task-complete-batch store)
+159
(dict ids: (list "t-dead" "t-5afe")))))
+160
(assert-true (string-contains? result "REFUSING"))
+161
(assert-true (string-contains? result "t-5afe")))
+162
;; the real task survived
+163
(assert-false (folio-task-done?
+164
(car (folio-project-tasks (proj dir "real")))))
+165
;; the unambiguous one still completed
+166
(let ((safe (find (lambda (t) (equal? (folio-task-id t) "t-5afe"))
+167
(read-inbox store))))
+168
(assert-true (folio-task-done? safe)))))))
+169
+170
(test "three copies are all named"
+171
(call-with-temp-directory
+172
(lambda (dir)
+173
(let ((store (make-folio-store dir)))
+174
;; t-bf97 really does have three live open copies in the estate
+175
(mkproject dir "one" "- [ ] copy in one {id: t-bf97}\n")
+176
(mkproject dir "two" "- [ ] copy in two {id: t-bf97}\n")
+177
(mkproject dir "three" "- [ ] copy in three {id: t-bf97}\n")
+178
(let ((result (complete-task-in-store! store "t-bf97")))
+179
(assert-true (string-contains? result "matches 3 tasks"))
+180
(assert-true (string-contains? result "copy in one"))
+181
(assert-true (string-contains? result "copy in two"))
+182
(assert-true (string-contains? result "copy in three")))))))
+183
+184
;; A completed copy archived under ## Log is still reachable by the
+185
;; mutators, so it must count toward ambiguity.
+186
(test "a copy archived under ## Log counts as a match"
+187
(call-with-temp-directory
+188
(lambda (dir)
+189
(let ((store (make-folio-store dir)))
+190
(write-file-string
+191
(path-join (path-join dir "projects") "p.md")
+192
(string-append
+193
"---\nstatus: active\ngoal: g\narea: general\nlast-updated: 2026-08-02\n---\n\n"
+194
"# p\n\n## Tasks\n- [ ] live task {id: t-dead}\n\n"
+195
"## Log\n\n### 2026-08-02\n\nCompleted:\n"
+196
" - [x] archived copy {id: t-dead, completed: 2026-08-02}\n"))
+197
(let ((result (complete-task-in-store! store "t-dead")))
+198
(assert-true (string-contains? result "REFUSING"))
+199
(assert-true (string-contains? result "archived copy"))))))))