AtlatestRepositoryfolio
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, so4
;;; collisions are routine at estate scale, not exotic: 1291 task lines over a5
;;; 65536 space predicts ~12 colliding ids, and 14 were found in the real6
;;; folio on 2026-08-02. One of them cost a live task: a disposable probe was7
;;; minted `t-de62`, which already belonged to an open task in another8
;;; project, and the probe's cleanup completed the REAL one.9
;;;10
;;; The old behaviour picked whichever task the search reached first and11
;;; returned a success message naming the id, which reads as confirmation.13
(import (sigil test)14
(sigil struct)15
(sigil string)16
(sigil dict)17
(sigil math)18
(sigil fs)19
(sigil path)20
(folio store)21
(folio task)22
(folio inbox)23
(folio project)24
(folio tools))26
(define (mkproject dir name body)27
(write-file-string28
(path-join (path-join dir "projects") (string-append name ".md"))29
(string-append30
"---\nstatus: active\ngoal: g\narea: general\nlast-updated: 2026-08-02\n---\n\n"31
"# " name "\n\n## Tasks\n" body "\n## Log\n")))33
(define (proj dir name)34
(read-project (path-join (path-join dir "projects") (string-append name ".md"))))36
;; ============================================================37
;; Positive controls -- a UNIQUE id must still work everywhere.38
;; A guard that refuses everything is as useless as one that refuses nothing.39
;; ============================================================41
(test-group "unique ids are unaffected"42
(test "complete works on a unique id"43
(call-with-temp-directory44
(lambda (dir)45
(let* ((store (make-folio-store dir))46
(t (inbox-add! store "only one" '())))47
(assert-true (string-contains?48
(complete-task-in-store! store (folio-task-id t))49
"Completed"))50
(assert-true (folio-task-done? (car (read-inbox store))))))))52
(test "update works on a unique id"53
(call-with-temp-directory54
(lambda (dir)55
(let* ((store (make-folio-store dir))56
(t (inbox-add! store "only one" '())))57
(assert-true (string-contains?58
(update-task-in-store! store (folio-task-id t)59
(dict priority: "high"))60
"Updated"))61
(assert-equal "high" (task-priority (car (read-inbox store))))))))63
(test "move works on a unique id"64
(call-with-temp-directory65
(lambda (dir)66
(let ((store (make-folio-store dir)))67
(mkproject dir "target" "")68
(let ((t (inbox-add! store "only one" '())))69
(assert-true (string-contains?70
(move-task-in-store! store (folio-task-id t) "target")71
"Moved"))72
(assert-equal 1 (length (folio-project-tasks (proj dir "target")))))))))74
(test "reopen works on a unique id"75
(call-with-temp-directory76
(lambda (dir)77
(let* ((store (make-folio-store dir))78
(t (inbox-add! store "only one" '())))79
(complete-task-in-store! store (folio-task-id t))80
(assert-true (string-contains?81
(uncomplete-task-in-store! store (folio-task-id t))82
"Reopened"))83
(assert-false (folio-task-done? (car (read-inbox store)))))))))85
;; ============================================================86
;; The refusal87
;; ============================================================89
(test-group "ambiguous ids are refused, not guessed"90
(test "complete refuses and names both tasks"91
(call-with-temp-directory92
(lambda (dir)93
(let ((store (make-folio-store dir)))94
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")95
(write-file-string (store-inbox-path store)96
"# Inbox\n- [ ] disposable probe item {id: t-dead}\n")97
(let ((result (complete-task-in-store! store "t-dead")))98
(assert-true (string-contains? result "REFUSING"))99
(assert-true (string-contains? result "matches 2 tasks"))100
;; both descriptions present, so the caller can tell them apart101
(assert-true (string-contains? result "REAL live task"))102
(assert-true (string-contains? result "disposable probe item")))103
;; and NOTHING was mutated104
(assert-false (folio-task-done? (car (read-inbox store))))105
(assert-false (folio-task-done?106
(car (folio-project-tasks (proj dir "real")))))))))108
(test "update refuses and mutates nothing"109
(call-with-temp-directory110
(lambda (dir)111
(let ((store (make-folio-store dir)))112
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")113
(write-file-string (store-inbox-path store)114
"# Inbox\n- [ ] probe item {id: t-dead}\n")115
(assert-true (string-contains?116
(update-task-in-store! store "t-dead" (dict priority: "high"))117
"REFUSING"))118
(assert-false (task-priority (car (read-inbox store))))119
(assert-false (task-priority120
(car (folio-project-tasks (proj dir "real")))))))))122
(test "move refuses and mutates nothing"123
(call-with-temp-directory124
(lambda (dir)125
(let ((store (make-folio-store dir)))126
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")127
(mkproject dir "target" "")128
(write-file-string (store-inbox-path store)129
"# Inbox\n- [ ] probe item {id: t-dead}\n")130
(assert-true (string-contains?131
(move-task-in-store! store "t-dead" "target")132
"REFUSING"))133
(assert-equal 1 (length (read-inbox store)))134
(assert-equal 0 (length (folio-project-tasks (proj dir "target"))))135
(assert-equal 1 (length (folio-project-tasks (proj dir "real"))))))))137
(test "reopen refuses"138
(call-with-temp-directory139
(lambda (dir)140
(let ((store (make-folio-store dir)))141
(mkproject dir "real" "- [x] REAL done task {id: t-dead}\n")142
(write-file-string (store-inbox-path store)143
"# Inbox\n- [x] probe item {id: t-dead}\n")144
(assert-true (string-contains?145
(uncomplete-task-in-store! store "t-dead")146
"REFUSING"))))))148
;; This is the incident: cleanup ran a BATCH, which is where it did damage.149
(test "task-complete-batch refuses the ambiguous id and still does the others"150
(call-with-temp-directory151
(lambda (dir)152
(let ((store (make-folio-store dir)))153
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")154
(write-file-string (store-inbox-path store)155
(string-append156
"# Inbox\n"157
"- [ ] probe item {id: t-dead}\n"158
"- [ ] unrelated safe item {id: t-5afe}\n"))159
(let ((result ((make-tool-task-complete-batch store)160
(dict ids: (list "t-dead" "t-5afe")))))161
(assert-true (string-contains? result "REFUSING"))162
(assert-true (string-contains? result "t-5afe")))163
;; the real task survived164
(assert-false (folio-task-done?165
(car (folio-project-tasks (proj dir "real")))))166
;; the unambiguous one still completed167
(let ((safe (find (lambda (t) (equal? (folio-task-id t) "t-5afe"))168
(read-inbox store))))169
(assert-true (folio-task-done? safe)))))))171
(test "three copies are all named"172
(call-with-temp-directory173
(lambda (dir)174
(let ((store (make-folio-store dir)))175
;; t-bf97 really does have three live open copies in the estate176
(mkproject dir "one" "- [ ] copy in one {id: t-bf97}\n")177
(mkproject dir "two" "- [ ] copy in two {id: t-bf97}\n")178
(mkproject dir "three" "- [ ] copy in three {id: t-bf97}\n")179
(let ((result (complete-task-in-store! store "t-bf97")))180
(assert-true (string-contains? result "matches 3 tasks"))181
(assert-true (string-contains? result "copy in one"))182
(assert-true (string-contains? result "copy in two"))183
(assert-true (string-contains? result "copy in three")))))))185
;; A completed copy archived under ## Log is still reachable by the186
;; mutators, so it must count toward ambiguity.187
(test "a copy archived under ## Log counts as a match"188
(call-with-temp-directory189
(lambda (dir)190
(let ((store (make-folio-store dir)))191
(write-file-string192
(path-join (path-join dir "projects") "p.md")193
(string-append194
"---\nstatus: active\ngoal: g\narea: general\nlast-updated: 2026-08-02\n---\n\n"195
"# p\n\n## Tasks\n- [ ] live task {id: t-dead}\n\n"196
"## Log\n\n### 2026-08-02\n\nCompleted:\n"197
" - [x] archived copy {id: t-dead, completed: 2026-08-02}\n"))198
(let ((result (complete-task-in-store! store "t-dead")))199
(assert-true (string-contains? result "REFUSING"))200
(assert-true (string-contains? result "archived copy"))))))))202
;; ============================================================203
;; Mint-time uniqueness204
;; ============================================================206
(test-group "newly minted ids avoid ids already in use"207
;; POSITIVE CONTROL: the probe can observe a mint at all.208
(test "positive control: a fresh capture gets an id"209
(call-with-temp-directory210
(lambda (dir)211
(let* ((store (make-folio-store dir))212
(result ((make-tool-inbox-add store) (dict text: "something"))))213
(assert-true (string-contains? result "Added to inbox"))214
(assert-true (string-starts-with?215
(folio-task-id (car (read-inbox store))) "t-"))))))217
;; DETERMINISTIC: the predicate the mint depends on. If this is wrong the218
;; mint cannot be right, and unlike the statistical test below it cannot219
;; pass by luck.220
(test "find-all-tasks-by-id reports an occupied id as in use"221
(call-with-temp-directory222
(lambda (dir)223
(let ((store (make-folio-store dir)))224
(mkproject dir "p" "- [ ] taken {id: t-0abc}\n")225
(assert-equal 1 (length (find-all-tasks-by-id store "t-0abc")))226
(assert-equal 0 (length (find-all-tasks-by-id store "t-0abd")))))))228
;; STATISTICAL, and labelled as such. A single mint cannot be made to229
;; collide deterministically against a 65536-space, so this occupies 4096230
;; ids (t-0000..t-0fff, 6.25% of the space) and mints 120 times. A mint231
;; with NO uniqueness check survives that with probability 0.9375^120,232
;; about 0.04%.233
;;234
;; An earlier version of this test occupied 200 ids and minted 40 times,235
;; which a check-free mint survived about 89% of the time -- it passed236
;; under sabotage and was therefore not a gate at all.237
(test "120 mints against 6.25% occupancy never collide"238
(call-with-temp-directory239
(lambda (dir)240
(let ((store (make-folio-store dir))241
(hexc "0123456789abcdef"))242
(mkproject dir "occupied"243
(string-join244
(map (lambda (i)245
(format "- [ ] occupied ~a {id: t-0~a~a~a}" i246
(string-ref hexc (modulo (quotient i 256) 16))247
(string-ref hexc (modulo (quotient i 16) 16))248
(string-ref hexc (modulo i 16))))249
(iota 4096))250
"\n"))251
(let loop ((i 0))252
(when (< i 120)253
((make-tool-inbox-add store) (dict text: (format "fresh ~a" i)))254
(loop (+ i 1))))255
(let ((dups (filter (lambda (id)256
(> (length (find-all-tasks-by-id store id)) 1))257
(map folio-task-id (read-inbox store)))))258
(assert-equal '() dups))))))260
(test "task-add into a project also mints uniquely"261
(call-with-temp-directory262
(lambda (dir)263
(let ((store (make-folio-store dir)))264
(mkproject dir "target" "- [ ] existing {id: t-aaaa}\n")265
((make-tool-task-add store) (dict text: "new one" project: "target"))266
(let ((ids (map folio-task-id (folio-project-tasks (proj dir "target")))))267
(assert-equal 2 (length ids))268
(assert-false (equal? (car ids) (cadr ids)))))))))270
;; ============================================================271
;; Id width and deterministic file order272
;; ============================================================274
(test-group "ids are minted six hex wide, and short ids still work"275
(test "a minted id is t- plus six hex"276
(call-with-temp-directory277
(lambda (dir)278
(let* ((store (make-folio-store dir))279
(t (car (begin ((make-tool-inbox-add store) (dict text: "x"))280
(read-inbox store)))))281
(assert-equal 8 (string-length (folio-task-id t)))282
(assert-true (string-starts-with? (folio-task-id t) "t-"))))))284
;; accept-short/mint-long: the 1274 existing 4-hex ids must keep resolving285
(test "a legacy 4-hex id still parses and still mutates"286
(call-with-temp-directory287
(lambda (dir)288
(let ((store (make-folio-store dir)))289
(write-file-string (store-inbox-path store)290
"# Inbox\n- [ ] legacy item {id: t-a3f2}\n")291
(assert-equal "t-a3f2" (folio-task-id (car (read-inbox store))))292
(assert-true (string-contains?293
(update-task-in-store! store "t-a3f2" (dict priority: "high"))294
"Updated"))295
(assert-equal "high" (task-priority (car (read-inbox store))))))))297
(test "both widths coexist in one store and resolve independently"298
(call-with-temp-directory299
(lambda (dir)300
(let ((store (make-folio-store dir)))301
(write-file-string (store-inbox-path store)302
"# Inbox\n- [ ] old {id: t-a3f2}\n- [ ] new {id: t-9c1e77}\n")303
(complete-task-in-store! store "t-9c1e77")304
(let ((after (read-inbox store)))305
(assert-false (folio-task-done? (car after)))306
(assert-true (folio-task-done? (cadr after)))))))))308
(test-group "project file order is deterministic"309
(test "store-project-files is sorted, not readdir order"310
(call-with-temp-directory311
(lambda (dir)312
(let ((store (make-folio-store dir)))313
(for-each (lambda (n) (mkproject dir n ""))314
(list "zeta" "alpha" "mu" "beta"))315
(let ((names (map path-basename (store-project-files store))))316
(assert-equal (list "alpha.md" "beta.md" "mu.md" "zeta.md")317
names)))))))319
;; ============================================================320
;; The `from` qualifier: address an ambiguous id without renaming321
;; ============================================================323
(test-group "from: disambiguates a duplicated id"324
(test "the refusal message tells the caller how to retry"325
(call-with-temp-directory326
(lambda (dir)327
(let ((store (make-folio-store dir)))328
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")329
(write-file-string (store-inbox-path store)330
"# Inbox\n- [ ] probe item {id: t-dead}\n")331
(let ((result (complete-task-in-store! store "t-dead")))332
(assert-true (string-contains? result "Retry with project:")))))))334
(test "from: a project completes only that project's copy"335
(call-with-temp-directory336
(lambda (dir)337
(let ((store (make-folio-store dir)))338
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")339
(write-file-string (store-inbox-path store)340
"# Inbox\n- [ ] probe item {id: t-dead}\n")341
(assert-true (string-contains?342
(complete-task-in-store! store "t-dead" "real")343
"Completed"))344
(assert-true (folio-task-done?345
(car (folio-project-tasks (proj dir "real")))))346
;; the inbox copy is untouched347
(assert-false (folio-task-done? (car (read-inbox store))))))))349
(test "from: inbox completes only the inbox copy"350
(call-with-temp-directory351
(lambda (dir)352
(let ((store (make-folio-store dir)))353
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")354
(write-file-string (store-inbox-path store)355
"# Inbox\n- [ ] probe item {id: t-dead}\n")356
(complete-task-in-store! store "t-dead" "inbox")357
(assert-true (folio-task-done? (car (read-inbox store))))358
(assert-false (folio-task-done?359
(car (folio-project-tasks (proj dir "real")))))))))361
;; t-bf97 in the real estate: three live open copies, none closable362
;; without this.363
(test "from: picks one of three copies and leaves the others"364
(call-with-temp-directory365
(lambda (dir)366
(let ((store (make-folio-store dir)))367
(mkproject dir "one" "- [ ] copy one {id: t-bf97}\n")368
(mkproject dir "two" "- [ ] copy two {id: t-bf97}\n")369
(mkproject dir "three" "- [ ] copy three {id: t-bf97}\n")370
(assert-true (string-contains?371
(complete-task-in-store! store "t-bf97" "two")372
"Completed"))373
(assert-false (folio-task-done? (car (folio-project-tasks (proj dir "one")))))374
(assert-true (folio-task-done? (car (folio-project-tasks (proj dir "two")))))375
(assert-false (folio-task-done? (car (folio-project-tasks (proj dir "three")))))))))377
(test "from: a scope that still matches two is STILL refused"378
(call-with-temp-directory379
(lambda (dir)380
(let ((store (make-folio-store dir)))381
;; two copies inside ONE file -- the scope cannot separate them382
(mkproject dir "dup" "- [ ] first copy {id: t-dead}\n- [ ] second copy {id: t-dead}\n")383
(assert-true (string-contains?384
(complete-task-in-store! store "t-dead" "dup")385
"REFUSING"))))))387
(test "from: a scope naming the wrong project finds nothing"388
(call-with-temp-directory389
(lambda (dir)390
(let ((store (make-folio-store dir)))391
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")392
(mkproject dir "elsewhere" "")393
(write-file-string (store-inbox-path store)394
"# Inbox\n- [ ] probe item {id: t-dead}\n")395
(assert-true (string-contains?396
(complete-task-in-store! store "t-dead" "elsewhere")397
"not found"))398
;; and nothing was mutated399
(assert-false (folio-task-done? (car (read-inbox store))))400
(assert-false (folio-task-done?401
(car (folio-project-tasks (proj dir "real")))))))))403
(test "move accepts from: to pick its source"404
(call-with-temp-directory405
(lambda (dir)406
(let ((store (make-folio-store dir)))407
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")408
(mkproject dir "target" "")409
(write-file-string (store-inbox-path store)410
"# Inbox\n- [ ] probe item {id: t-dead}\n")411
(assert-true (string-contains?412
(move-task-in-store! store "t-dead" "target" "inbox")413
"Moved"))414
;; the probe moved; the real task stayed put415
(assert-equal 0 (length (read-inbox store)))416
(assert-equal 1 (length (folio-project-tasks (proj dir "real"))))417
(assert-equal "probe item"418
(folio-task-text419
(car (folio-project-tasks (proj dir "target")))))))))421
(test "update accepts from: through its args dict"422
(call-with-temp-directory423
(lambda (dir)424
(let ((store (make-folio-store dir)))425
(mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n")426
(write-file-string (store-inbox-path store)427
"# Inbox\n- [ ] probe item {id: t-dead}\n")428
(assert-true (string-contains?429
(update-task-in-store! store "t-dead"430
(dict priority: "high" from: "real"))431
"Updated"))432
(assert-equal "high" (task-priority433
(car (folio-project-tasks (proj dir "real")))))434
(assert-false (task-priority (car (read-inbox store))))))))436
;; a unique id must not need the qualifier437
(test "from: is never required for a unique id"438
(call-with-temp-directory439
(lambda (dir)440
(let* ((store (make-folio-store dir))441
(t (inbox-add! store "solo" '())))442
(assert-true (string-contains?443
(complete-task-in-store! store (folio-task-id t))444
"Completed")))))))