Widen ids to 6 hex, sort project files, add a from: disambiguator
WIDER IDS. generate-task-id now emits six hex, not four. Four is a 65,536 space; with no uniqueness check, 1291 task lines predicts ~12.5 colliding ids and the estate had 14 -- the arithmetic working exactly as designed. Six gives 16,777,216 and predicts ~0.05 at the same corpus size.
Accept short, mint long: nothing in folio validates id shape and length was decided in exactly one place, so all 1274 existing 4-hex ids stay valid and every reference to them keeps resolving. There is no migration; the two widths coexist, and there is a test asserting they do.
SORTED PROJECT FILES. store-project-files returned raw directory-list (readdir) order. Every id-addressed mutation searches that list and stops at the first match, so with a duplicated id the VICTIM was chosen by readdir -- unstable across machines and unreproducible between runs. Sorting does not fix ambiguity; it makes the ambiguity deterministic, so a reproduction stays reproducible.
A from: DISAMBIGUATOR. complete, reopen, update and move take an optional from naming the project (or "inbox") holding the task meant, and the refusal message now ends by suggesting it. This addresses a duplicated id WITHOUT renaming anything, so no reference in any note, brief or commit message breaks. It is what makes t-bf97's three live copies closable.
A scope that still matches two is still refused; a scope naming the wrong project finds nothing and mutates nothing. Sabotaging the scope filter reddens six of the nine qualifier tests while the two scope-independent controls stay green.
Also corrects a comment on move-task-in-store! that asserted ids are globally unique. They are not, and I wrote that claim myself.
src/folio/id.sgl | 16 +++++++++++++--
src/folio/store.sgl | 18 ++++++++++++++---
src/folio/tools.sgl | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------------------------
test/test-id-ambiguity.sgl | 176 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 307 insertions(+), 55 deletions(-)src/folio/id.sglmodified
(random-seed! (current-jiffy)) (set! *seeded?* #t))) ;;; Generate a random 4-hex-char task ID. ;;; Generate a random 6-hex-char task ID. ;;; ;;; Returns a string like "t-a3f2". ;;; Returns a string like "t-9c1e77". ;;; ;;; SIX, not four. Four hex is a 65,536 space, and with no uniqueness ;;; check anywhere, 1291 task lines predicts ~12.5 colliding ids -- the ;;; estate had 14, which is the arithmetic working exactly as designed. ;;; Six gives 16,777,216 and predicts ~0.05 at the same corpus size. ;;; ;;; ACCEPT SHORT, MINT LONG. Nothing in folio validates id shape and ;;; length is decided only here, so the 1274 existing 4-hex ids stay valid ;;; forever and every reference to them keeps resolving. There is no ;;; migration: the two widths simply coexist. (define (generate-task-id) (: -> string?) (ensure-seeded!) (string-append "t-" (string (string-ref hex-chars (random-integer 16)) (string-ref hex-chars (random-integer 16)) (string-ref hex-chars (random-integer 16)) (string-ref hex-chars (random-integer 16)) (string-ref hex-chars (random-integer 16))src/folio/store.sglmodified
(import (sigil core) (sigil struct) (sigil string) (sigil list) (sigil path) (sigil fs)) (export make-folio-store (: folio-store? -> list?) (let ((dir (store-project-dir store))) (if (directory-exists? dir) (filter (lambda (f) (string-ends-with? f ".md")) (map (lambda (name) (path-join dir name)) (or (directory-list dir) '()))) ;; SORTED. `directory-list` returns raw readdir order, so without ;; this the order varies by filesystem and shifts whenever a ;; project file is added or removed. Every id-addressed mutation ;; searches this list and stops at the first match, so with a ;; duplicated id the VICTIM was decided by readdir -- unstable ;; across machines and unreproducible between runs. ;; ;; Sorting does not fix ambiguity (that is `refuse-if-ambiguous`). ;; It makes the ambiguity deterministic, so a reproduction stays ;; reproducible. (list-sort string<? (filter (lambda (f) (string-ends-with? f ".md")) (map (lambda (name) (path-join dir name)) (or (directory-list dir) '())))) '()))) ;;; List all .md files in the notes directory, recursively.src/folio/tools.sglmodified
;; Tool Schemas ;; ============================================================ ;; Shared description for the `from` disambiguator. An id that matches ;; more than one task is refused rather than guessed; `from` is how the ;; caller says which one they meant, WITHOUT renaming anything -- renaming ;; would break every reference to that id in notes, briefs and commit ;; messages. (define scope-arg-description "Disambiguator for a duplicated id: the project name (or \"inbox\") holding the task you mean. Only needed when folio refuses an ambiguous id.") (define no-args-schema '((type . "object") (properties . ()) (required . ("text")))) (define task-complete-schema '((type . "object") `((type . "object") (properties . ((id . ((type . "string") (description . "Task ID (e.g., t-a3f2)"))))) (description . "Task ID (e.g., t-a3f2)"))) (from . ((type . "string") (description . ,scope-arg-description))))) (required . ("id")))) (define task-update-schema '((type . "object") `((type . "object") (properties . ((id . ((type . "string") (description . "Task ID (e.g., t-a3f2)"))) (text . ((type . "string") (tags . ((type . "string") (description . "Set tags (space-separated)"))) (waiting . ((type . "string") (description . "What/who this is blocked on"))))) (description . "What/who this is blocked on"))) (from . ((type . "string") (description . ,scope-arg-description))))) (required . ("id")))) (define task-add-schema (required . ("text")))) (define task-move-schema '((type . "object") `((type . "object") (properties . ((id . ((type . "string") (description . "Task ID to move"))) (project . ((type . "string") (description . "Target project name (omit to move to inbox)"))))) (description . "Target project name (omit to move to inbox)"))) (from . ((type . "string") (description . ,scope-arg-description))))) (required . ("id")))) (define projects-schema (define (make-tool-task-complete store) (lambda (args) (let ((task-id (require-arg args id: "id"))) (complete-task-in-store! store task-id)))) (complete-task-in-store! store task-id (dict-ref args from: #f))))) (define (make-tool-task-uncomplete store) (lambda (args) (let ((task-id (require-arg args id: "id"))) (uncomplete-task-in-store! store task-id)))) (uncomplete-task-in-store! store task-id (dict-ref args from: #f))))) (define (make-tool-task-update store) (lambda (args) (lambda (args) (let* ((task-id (require-arg args id: "id")) (target-project (dict-ref args project: #f))) (move-task-in-store! store task-id target-project)))) (move-task-in-store! store task-id target-project (dict-ref args from: #f))))) ;; ============================================================ ;; Project Handlers (cons (store-inbox-path store) (store-project-files store))))) ;; Does this file belong to `scope`? "inbox" means inbox.md; anything else ;; is matched against the project file's basename without .md. (define (file-in-scope? file-path scope) (let ((base (path-basename file-path))) (if (equal? scope "inbox") (equal? base "inbox.md") (equal? base (string-append scope ".md"))))) ;;; Matches for `task-id`, narrowed to `scope` when one is given. ;;; ;;; This is the disambiguator for a duplicated id: rather than renaming a ;;; task -- which breaks every reference to it in notes, briefs and commit ;;; messages -- the CALLER says which one it meant. Nothing on disk ;;; changes, and the refusal message has already told them the names to ;;; choose from. (define (find-tasks-by-id-scoped store task-id scope) (: folio-store? string? (maybe string?) -> list?) (let ((all (find-all-tasks-by-id store task-id))) (if (not scope) all (filter (lambda (m) (file-in-scope? (car m) scope)) all)))) ;; The files a mutation may touch, narrowed to `scope` when one is given. (define (scoped-files store scope) (let ((all (cons (store-inbox-path store) (store-project-files store)))) (if (not scope) all (filter (lambda (f) (file-in-scope? f scope)) all)))) ;; Refuse an id-addressed mutation when the id resolves to more than one ;; task. Returns #f when it is safe to proceed, or a refusal message. ;; ;; list is unsorted `directory-list` order, so which task wins depends on ;; readdir, and any move can change it by removing the copy that was ;; winning. (define (refuse-if-ambiguous store task-id verb) (let ((matches (find-all-tasks-by-id store task-id))) (define (refuse-if-ambiguous store task-id verb . opts) (let* ((scope (if (pair? opts) (car opts) #f)) (matches (find-tasks-by-id-scoped store task-id scope))) (if (< (length matches) 2) #f (string-join (folio-task-text (cdr m)))) matches) (list "" "Task ids in this estate are not unique. Give one of them a new id to disambiguate, then retry.")) "Task ids in this estate are not unique. Retry with project: to say which one you mean --" (format " e.g. project: \"~a\"" (let ((base (path-basename (car (car matches))))) (if (equal? base "inbox.md") "inbox" (substring base 0 (- (string-length base) 3))))))) "\n")))) ;; Complete a task by ID, searching across inbox and all project files. (define (complete-task-in-store! store task-id) (or (refuse-if-ambiguous store task-id "complete") (let ((files (cons (store-inbox-path store) (store-project-files store)))) (let loop ((rest files)) (if (null? rest) (format "Task ~a not found." task-id) (if (complete-task-in-file! (car rest) task-id) (format "Completed task ~a." task-id) (loop (cdr rest)))))))) (define (complete-task-in-store! store task-id . opts) (let ((scope (if (pair? opts) (car opts) #f))) (or (refuse-if-ambiguous store task-id "complete" scope) (let loop ((rest (scoped-files store scope))) (if (null? rest) (format "Task ~a not found." task-id) (if (complete-task-in-file! (car rest) task-id) (format "Completed task ~a." task-id) (loop (cdr rest)))))))) ;; Try to complete a task in a specific file. Returns #t if found. (define (complete-task-in-file! file-path task-id) found?))) ;; Reopen a completed task by ID. (define (uncomplete-task-in-store! store task-id) (or (refuse-if-ambiguous store task-id "reopen") (let ((files (cons (store-inbox-path store) (store-project-files store)))) (let loop ((rest files)) (if (null? rest) (format "Task ~a not found." task-id) (if (uncomplete-task-in-file! (car rest) task-id) (format "Reopened task ~a." task-id) (loop (cdr rest)))))))) (define (uncomplete-task-in-store! store task-id . opts) (let ((scope (if (pair? opts) (car opts) #f))) (or (refuse-if-ambiguous store task-id "reopen" scope) (let loop ((rest (scoped-files store scope))) (if (null? rest) (format "Task ~a not found." task-id) (if (uncomplete-task-in-file! (car rest) task-id) (format "Reopened task ~a." task-id) (loop (cdr rest)))))))) ;; Try to uncomplete a task in a specific file. Returns #t if found. (define (uncomplete-task-in-file! file-path task-id) ;; Update task metadata by ID. (define (update-task-in-store! store task-id args) (or (refuse-if-ambiguous store task-id "update") (let ((files (cons (store-inbox-path store) (store-project-files store)))) (let loop ((rest files)) (if (null? rest) (format "Task ~a not found." task-id) (if (update-task-in-file! (car rest) task-id args) (format "Updated task ~a." task-id) (loop (cdr rest)))))))) (let ((scope (dict-ref args from: #f))) (or (refuse-if-ambiguous store task-id "update" scope) (let loop ((rest (scoped-files store scope))) (if (null? rest) (format "Task ~a not found." task-id) (if (update-task-in-file! (car rest) task-id args) (format "Updated task ~a." task-id) (loop (cdr rest)))))))) ;; Try to update a task's metadata and/or text in a specific file. (define (update-task-in-file! file-path task-id args) ;; Move a task by ID to a target project (or inbox if #f). ;; ;; The id is preserved in every direction. Task ids are globally unique ;; random hex (`generate-task-id`) with no per-project id space, so a move ;; has never needed a new one; renumbering only ever stranded references. (define (move-task-in-store! store task-id target-project) (or (refuse-if-ambiguous store task-id "move") (let ((files (cons (store-inbox-path store) (store-project-files store)))) ;; Find the task in any file (let loop ((rest files)) ;; The id is preserved in every direction. There is no per-project id ;; space, so a move never needed a new id; renumbering only ever stranded ;; held references. ;; ;; Ids are NOT globally unique -- an earlier version of this comment said ;; they were, which was inferred from the absence of scoping and is false. ;; They are random hex with no uniqueness check, so a duplicate is ;; possible; `refuse-if-ambiguous` is what keeps a move from guessing. (define (move-task-in-store! store task-id target-project . opts) (let ((scope (if (pair? opts) (car opts) #f))) (or (refuse-if-ambiguous store task-id "move" scope) ;; Find the task in any file the scope allows (let loop ((rest (scoped-files store scope))) (if (null? rest) (format "Task ~a not found." task-id) (let ((task (find-and-remove-task! (car rest) task-id)))test/test-id-ambiguity.sglmodified
(let ((ids (map folio-task-id (folio-project-tasks (proj dir "target"))))) (assert-equal 2 (length ids)) (assert-false (equal? (car ids) (cadr ids)))))))));; ============================================================;; Id width and deterministic file order;; ============================================================(test-group "ids are minted six hex wide, and short ids still work" (test "a minted id is t- plus six hex" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (t (car (begin ((make-tool-inbox-add store) (dict text: "x")) (read-inbox store))))) (assert-equal 8 (string-length (folio-task-id t))) (assert-true (string-starts-with? (folio-task-id t) "t-")))))) ;; accept-short/mint-long: the 1274 existing 4-hex ids must keep resolving (test "a legacy 4-hex id still parses and still mutates" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) (write-file-string (store-inbox-path store) "# Inbox\n- [ ] legacy item {id: t-a3f2}\n") (assert-equal "t-a3f2" (folio-task-id (car (read-inbox store)))) (assert-true (string-contains? (update-task-in-store! store "t-a3f2" (dict priority: "high")) "Updated")) (assert-equal "high" (task-priority (car (read-inbox store)))))))) (test "both widths coexist in one store and resolve independently" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) (write-file-string (store-inbox-path store) "# Inbox\n- [ ] old {id: t-a3f2}\n- [ ] new {id: t-9c1e77}\n") (complete-task-in-store! store "t-9c1e77") (let ((after (read-inbox store))) (assert-false (folio-task-done? (car after))) (assert-true (folio-task-done? (cadr after)))))))))(test-group "project file order is deterministic" (test "store-project-files is sorted, not readdir order" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) (for-each (lambda (n) (mkproject dir n "")) (list "zeta" "alpha" "mu" "beta")) (let ((names (map path-basename (store-project-files store)))) (assert-equal (list "alpha.md" "beta.md" "mu.md" "zeta.md") names)))))));; ============================================================;; The `from` qualifier: address an ambiguous id without renaming;; ============================================================(test-group "from: disambiguates a duplicated id" (test "the refusal message tells the caller how to retry" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) (mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n") (write-file-string (store-inbox-path store) "# Inbox\n- [ ] probe item {id: t-dead}\n") (let ((result (complete-task-in-store! store "t-dead"))) (assert-true (string-contains? result "Retry with project:"))))))) (test "from: a project completes only that project's copy" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) (mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n") (write-file-string (store-inbox-path store) "# Inbox\n- [ ] probe item {id: t-dead}\n") (assert-true (string-contains? (complete-task-in-store! store "t-dead" "real") "Completed")) (assert-true (folio-task-done? (car (folio-project-tasks (proj dir "real"))))) ;; the inbox copy is untouched (assert-false (folio-task-done? (car (read-inbox store)))))))) (test "from: inbox completes only the inbox copy" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) (mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n") (write-file-string (store-inbox-path store) "# Inbox\n- [ ] probe item {id: t-dead}\n") (complete-task-in-store! store "t-dead" "inbox") (assert-true (folio-task-done? (car (read-inbox store)))) (assert-false (folio-task-done? (car (folio-project-tasks (proj dir "real"))))))))) ;; t-bf97 in the real estate: three live open copies, none closable ;; without this. (test "from: picks one of three copies and leaves the others" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) (mkproject dir "one" "- [ ] copy one {id: t-bf97}\n") (mkproject dir "two" "- [ ] copy two {id: t-bf97}\n") (mkproject dir "three" "- [ ] copy three {id: t-bf97}\n") (assert-true (string-contains? (complete-task-in-store! store "t-bf97" "two") "Completed")) (assert-false (folio-task-done? (car (folio-project-tasks (proj dir "one"))))) (assert-true (folio-task-done? (car (folio-project-tasks (proj dir "two"))))) (assert-false (folio-task-done? (car (folio-project-tasks (proj dir "three"))))))))) (test "from: a scope that still matches two is STILL refused" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) ;; two copies inside ONE file -- the scope cannot separate them (mkproject dir "dup" "- [ ] first copy {id: t-dead}\n- [ ] second copy {id: t-dead}\n") (assert-true (string-contains? (complete-task-in-store! store "t-dead" "dup") "REFUSING")))))) (test "from: a scope naming the wrong project finds nothing" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) (mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n") (mkproject dir "elsewhere" "") (write-file-string (store-inbox-path store) "# Inbox\n- [ ] probe item {id: t-dead}\n") (assert-true (string-contains? (complete-task-in-store! store "t-dead" "elsewhere") "not found")) ;; and nothing was mutated (assert-false (folio-task-done? (car (read-inbox store)))) (assert-false (folio-task-done? (car (folio-project-tasks (proj dir "real"))))))))) (test "move accepts from: to pick its source" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) (mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n") (mkproject dir "target" "") (write-file-string (store-inbox-path store) "# Inbox\n- [ ] probe item {id: t-dead}\n") (assert-true (string-contains? (move-task-in-store! store "t-dead" "target" "inbox") "Moved")) ;; the probe moved; the real task stayed put (assert-equal 0 (length (read-inbox store))) (assert-equal 1 (length (folio-project-tasks (proj dir "real")))) (assert-equal "probe item" (folio-task-text (car (folio-project-tasks (proj dir "target"))))))))) (test "update accepts from: through its args dict" (call-with-temp-directory (lambda (dir) (let ((store (make-folio-store dir))) (mkproject dir "real" "- [ ] REAL live task {id: t-dead}\n") (write-file-string (store-inbox-path store) "# Inbox\n- [ ] probe item {id: t-dead}\n") (assert-true (string-contains? (update-task-in-store! store "t-dead" (dict priority: "high" from: "real")) "Updated")) (assert-equal "high" (task-priority (car (folio-project-tasks (proj dir "real"))))) (assert-false (task-priority (car (read-inbox store)))))))) ;; a unique id must not need the qualifier (test "from: is never required for a unique id" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (t (inbox-add! store "solo" '()))) (assert-true (string-contains? (complete-task-in-store! store (folio-task-id t)) "Completed")))))))