AtlatestRepositoryfolio

folio / tree / testtest-id-ambiguity.sgl

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.
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-string
28 (path-join (path-join dir "projects") (string-append name ".md"))
29 (string-append
30 "---\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-directory
44 (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-directory
54 (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-directory
65 (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-directory
76 (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 refusal
87;; ============================================================
89(test-group "ambiguous ids are refused, not guessed"
90 (test "complete refuses and names both tasks"
91 (call-with-temp-directory
92 (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 apart
101 (assert-true (string-contains? result "REAL live task"))
102 (assert-true (string-contains? result "disposable probe item")))
103 ;; and NOTHING was mutated
104 (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-directory
110 (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-priority
120 (car (folio-project-tasks (proj dir "real")))))))))
122 (test "move refuses and mutates nothing"
123 (call-with-temp-directory
124 (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-directory
139 (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-directory
151 (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-append
156 "# 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 survived
164 (assert-false (folio-task-done?
165 (car (folio-project-tasks (proj dir "real")))))
166 ;; the unambiguous one still completed
167 (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-directory
173 (lambda (dir)
174 (let ((store (make-folio-store dir)))
175 ;; t-bf97 really does have three live open copies in the estate
176 (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 the
186 ;; mutators, so it must count toward ambiguity.
187 (test "a copy archived under ## Log counts as a match"
188 (call-with-temp-directory
189 (lambda (dir)
190 (let ((store (make-folio-store dir)))
191 (write-file-string
192 (path-join (path-join dir "projects") "p.md")
193 (string-append
194 "---\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 uniqueness
204;; ============================================================
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-directory
210 (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 the
218 ;; mint cannot be right, and unlike the statistical test below it cannot
219 ;; pass by luck.
220 (test "find-all-tasks-by-id reports an occupied id as in use"
221 (call-with-temp-directory
222 (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 to
229 ;; collide deterministically against a 65536-space, so this occupies 4096
230 ;; ids (t-0000..t-0fff, 6.25% of the space) and mints 120 times. A mint
231 ;; 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 passed
236 ;; under sabotage and was therefore not a gate at all.
237 (test "120 mints against 6.25% occupancy never collide"
238 (call-with-temp-directory
239 (lambda (dir)
240 (let ((store (make-folio-store dir))
241 (hexc "0123456789abcdef"))
242 (mkproject dir "occupied"
243 (string-join
244 (map (lambda (i)
245 (format "- [ ] occupied ~a {id: t-0~a~a~a}" i
246 (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-directory
262 (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 order
272;; ============================================================
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-directory
277 (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 resolving
285 (test "a legacy 4-hex id still parses and still mutates"
286 (call-with-temp-directory
287 (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-directory
299 (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-directory
311 (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 renaming
321;; ============================================================
323(test-group "from: disambiguates a duplicated id"
324 (test "the refusal message tells the caller how to retry"
325 (call-with-temp-directory
326 (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-directory
336 (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 untouched
347 (assert-false (folio-task-done? (car (read-inbox store))))))))
349 (test "from: inbox completes only the inbox copy"
350 (call-with-temp-directory
351 (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 closable
362 ;; without this.
363 (test "from: picks one of three copies and leaves the others"
364 (call-with-temp-directory
365 (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-directory
379 (lambda (dir)
380 (let ((store (make-folio-store dir)))
381 ;; two copies inside ONE file -- the scope cannot separate them
382 (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-directory
389 (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 mutated
399 (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-directory
405 (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 put
415 (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-text
419 (car (folio-project-tasks (proj dir "target")))))))))
421 (test "update accepts from: through its args dict"
422 (call-with-temp-directory
423 (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-priority
433 (car (folio-project-tasks (proj dir "real")))))
434 (assert-false (task-priority (car (read-inbox store))))))))
436 ;; a unique id must not need the qualifier
437 (test "from: is never required for a unique id"
438 (call-with-temp-directory
439 (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")))))))