Commit316689a2Recorded2 Aug 2026Repositoryfolio

Mint task ids that are not already in use

Message

generate-task-id takes no store argument and so cannot check anything. Four random hex over this estate's 1291 task lines predicts ~12 colliding ids; 14 exist. Collisions are the expected output of the design.

The two creation handlers now mint through mint-unique-id, which rejects a candidate already present anywhere a mutation can reach and retries. Move paths are untouched: they carry an existing id by design, and creation is the only place a NEW collision can be born.

That last point was measured rather than assumed. Neither inbox-add-schema nor task-add-schema exposes an id property, and build-metadata-from-args can only emit source/ref/priority/due/tags -- so a caller cannot supply an id at creation, and supplied-id is reachable only from (folio-task-metadata task) on a move. 0.2.7's honour-a-supplied-id change therefore cannot create a collision; it can only preserve one that already existed, where the old re-roll might have broken it by luck.

THE FIRST VERSION OF THE MINT TEST WAS FALSE-GREEN. It occupied 200 ids and minted 40 times, which a check-free mint survives about 89% of the time -- it passed under sabotage. It now occupies 4096 (6.25% of the space) and mints 120 times, which a check-free mint survives with probability 0.9375^120, about 0.04%, and it reddens under the same sabotage. A deterministic companion test covers find-all-tasks-by-id, the predicate the mint depends on, since that one cannot pass by luck.

Changed
 src/folio/tools.sgl        | 28 ++++++++++++++++++++++++++--
 test/test-id-ambiguity.sgl | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 95 insertions(+), 2 deletions(-)
Diff
src/folio/tools.sglmodified
@@ -31,7 +31,10 @@
31
make-tool-inbox-sweep
32
make-tool-task-complete-batch
33
make-tool-inbox-triage
+34
make-tool-inbox-add
+35
make-tool-task-add
36
make-tool-task-move
+37
find-all-tasks-by-id
38
make-tool-task-move-batch
39
move-task-in-store!)
40
(begin
@@ -362,6 +365,25 @@
365
;; Inbox Handlers
366
;; ============================================================
367
+368
;; Mint a task id that is not already in use anywhere a mutation can reach.
+369
;;
+370
;; `generate-task-id` takes no store argument and therefore cannot check
+371
;; anything. Four random hex over this estate's 1291 task lines predicts
+372
;; ~12 colliding ids, and 14 exist -- collisions are the expected outcome
+373
;; of the design, not a defect in it.
+374
;;
+375
;; Checking at mint time makes NEW collisions impossible. It does nothing
+376
;; for the ones already on disk, which is why `refuse-if-ambiguous` is the
+377
;; more important half: this protects the future, that protects the corpus.
+378
(define (mint-unique-id store)
+379
(let loop ((tries 0))
+380
(let ((candidate (generate-task-id)))
+381
(cond
+382
((null? (find-all-tasks-by-id store candidate)) candidate)
+383
((> tries 200)
+384
(error "Could not mint an unused task id after 200 attempts; the id space may be exhausted"))
+385
(else (loop (+ tries 1)))))))
+386
387
(define (make-tool-inbox store)
388
(lambda (args)
389
(backfill-ids! (store-inbox-path store))
@@ -371,7 +393,8 @@
393
(define (make-tool-inbox-add store)
394
(lambda (args)
395
(let* ((text (require-arg args text: "text"))
374
(metadata (build-metadata-from-args args)))
+396
(metadata (cons (cons 'id (mint-unique-id store))
+397
(build-metadata-from-args args))))
398
(let ((task (inbox-add! store text metadata)))
399
(format "Added to inbox: ~a ~a"
400
(folio-task-id task)
@@ -409,7 +432,8 @@
432
(lambda (args)
433
(let* ((text (require-arg args text: "text"))
434
(project-name (dict-ref args project: #f))
412
(metadata (build-metadata-from-args args)))
+435
(metadata (cons (cons 'id (mint-unique-id store))
+436
(build-metadata-from-args args))))
437
(if project-name
438
;; Add to project
439
(let ((project-file (find-project-file store project-name)))
test/test-id-ambiguity.sglmodified
@@ -14,6 +14,7 @@
14
(sigil struct)
15
(sigil string)
16
(sigil dict)
+17
(sigil math)
18
(sigil fs)
19
(sigil path)
20
(folio store)
@@ -197,3 +198,71 @@
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"))))))))
+201
+202
;; ============================================================
+203
;; Mint-time uniqueness
+204
;; ============================================================
+205
+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-"))))))
+216
+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")))))))
+227
+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))))))
+259
+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)))))))))