Commit9e42639fRecorded25 Aug 2026Repositoryfolio

F3b: the 12-arm probe battery, and update-id becomes door-required

Message

test/f3b-probe.sgl: stand-down first (structural insert, bytes outside the splice byte-compared), the F3a mismatch regression arm over all four structural doors, BOTH recovery branches (a validated manifest COMPLETED with the action reported and the completed content checked against the INTENDED content, not just "no crash"; pre-commit debris ROLLED BACK with the target's revision pinned untouched), the 3-file rename with graph closure, the rename refusal quartet (raw-only prose finds what the semantic parser cannot see; markdown by policy; taken target; unknown), the referrer-guarded delete, the cross-file move with document re-addressing verified in the referrer's bytes, scoped-out wording, invalid-document read-only with the coordinator alive afterwards, body replacement, and a Markdown bystander whose hash is pinned across the ENTIRE battery. 12/12 green.

PRODUCT FINDING the new arms exposed, fixed at the doors: an id-less @update renders as an INVALID manuscript ("@update declares an id but its first argument is not a string literal") -- a latent F3a defect never hit because every F3a green path happened to pass an id; the postcheck refused it every time, so nothing corrupt could publish, but the failure surfaced as a confusing postcheck refusal instead of a caller error. update-id/id is now REQUIRED at folio-transition-status!, folio-append-update!, and insert-of-update, with the measurement in the message; the two probe arms that relied on optional ids carry ids.

Changed
 src/folio/doc/structural.sgl   |   4 ++-
 src/folio/doc/transaction.sgl  |   8 ++---
 test/f3a-txn-probe.sgl         |   4 +--
 test/f3b-probe.sgl             | 310 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/fixtures/f3b/broken.sgd   |   9 +++++
 test/fixtures/f3b/bystander.md |   4 +++
 test/fixtures/f3b/dest.sgd     |   8 +++++
 test/fixtures/f3b/owner.sgd    |  27 ++++++++++++++
 test/fixtures/f3b/rawonly.sgd  |   7 ++++
 test/fixtures/f3b/ref-one.sgd  |   8 +++++
 test/fixtures/f3b/ref-two.sgd  |  10 ++++++
 11 files changed, 392 insertions(+), 7 deletions(-)
Diff
src/folio/doc/structural.sglmodified
@@ -274,7 +274,9 @@
274
(when bad (error (string-append "folio-insert-node!: " bad)))))
275
((update)
276
(unless (string? timestamp)
−277
(error "folio-insert-node!: an update needs a timestamp"))))
+277
(error "folio-insert-node!: an update needs a timestamp"))
+278
(unless (and (string? id) (valid-node-id? id))
+279
(error "folio-insert-node!: an update needs an id -- an id-less @update renders as an invalid manuscript (measured: the inspector requires the id positional)"))))
280
(when body-text (%txn-require-safe-journal-text "folio-insert-node!" body-text))
281
(when (eq? kind 'task)
282
(when priority
src/folio/doc/transaction.sglmodified
@@ -957,8 +957,8 @@
957
(error "folio-transition-status!: timestamp is required (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ) -- the transaction layer owns no clock"))
958
(when (and commit (not (valid-commit? commit)))
959
(error "folio-transition-status!: commit must be 7-40 lowercase hex characters"))
−960
(when (and update-id (not (valid-node-id? update-id)))
−961
(error "folio-transition-status!: update-id must use flat node-id syntax"))
+960
(unless (and (string? update-id) (valid-node-id? update-id))
+961
(error "folio-transition-status!: update-id is REQUIRED (flat node-id syntax) -- an id-less @update renders as an invalid manuscript (measured: the inspector requires the id positional), and D12 addressability wants journal entries addressable anyway"))
962
(require-safe-journal-text "folio-transition-status!" update-text)
963
(require-valid-author "folio-transition-status!" author)
964
(let ((stage
@@ -1302,8 +1302,8 @@
1302
(error "folio-append-update!: timestamp is required (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)"))
1303
(when (and commit (not (valid-commit? commit)))
1304
(error "folio-append-update!: commit must be 7-40 lowercase hex characters"))
−1305
(when (and update-id (not (valid-node-id? update-id)))
−1306
(error "folio-append-update!: update-id must use flat node-id syntax"))
+1305
(unless (and (string? update-id) (valid-node-id? update-id))
+1306
(error "folio-append-update!: update-id is REQUIRED (flat node-id syntax) -- an id-less @update renders as an invalid manuscript (measured: the inspector requires the id positional)"))
1307
(require-safe-journal-text "folio-append-update!" update-text)
1308
(require-valid-author "folio-append-update!" author)
1309
(let ((stage
test/f3a-txn-probe.sglmodified
@@ -180,7 +180,7 @@
180
;; ready is the ONLY exit that was added: ready->inbox stays illegal.
181
(assert-refusal "ready->inbox refused"
182
(folio-transition-status! coord note "t-done" 'inbox (rev-of note)
−183
timestamp: "2026-08-25")
+183
timestamp: "2026-08-25" update-id: "u-illegal")
184
'illegal-transition)))
185
186
;; ---------------------------------------------------------------------------
@@ -516,7 +516,7 @@
516
(let ((rev (rev-of note)))
517
(let ((raised (guard (e (#t (if (exception? e) (exception-message e) "raised")))
518
(folio-transition-status! coord note "t-journalled" 'ready rev
−519
timestamp: "2026-08-25"
+519
timestamp: "2026-08-25" update-id: "u-inj"
520
update-text: "x} @task[\"t-evil\" title: \"E\" status: 'ready]{y")
521
#f)))
522
(assert-true "hostile update-text raises" (string? raised))
test/f3b-probe.sgladded
@@ -0,0 +1,310 @@
+1
;;; f3b-probe.sgl -- the in-process arm battery for gate-f3b.sh.
+2
;;;
+3
;;; Same harness contract as f3a-txn-probe.sgl: F3B_ARM_<name>_<GREEN|RED>
+4
;;; markers, F3B_PROBE_DONE arms=N green=N red=N, non-zero exit on any red,
+5
;;; F3B_PROBE_SELFTEST=1 proves the harness can fail. F3B_ROOT names a
+6
;;; DISPOSABLE copy of test/fixtures/f3b/. Kill/crash arms live in the gate,
+7
;;; which owns processes; the recovery-branch arms here CONSTRUCT the crashed
+8
;;; states through the same manifest API the engine uses, which produces the
+9
;;; identical bytes a real crash leaves (the gate's kill arms then prove the
+10
;;; all-or-nothing invariant against real SIGKILL).
+11
(import (sigil core) (sigil io) (sigil fs) (sigil string) (sigil path)
+12
(folio doc revision) (folio doc bounded) (folio doc service)
+13
(folio doc manifest) (folio doc coordinator) (folio doc transaction)
+14
(folio doc structural))
+15
+16
(define root (or (getenv "F3B_ROOT")
+17
(begin (display "F3B_PROBE SETUP-FAILED: F3B_ROOT is not set\n") (exit 3))))
+18
(define lib-dir (or (getenv "F3B_LIB") "build/dev/lib"))
+19
(define src-dir (or (getenv "F3B_SRC") "src"))
+20
(define (rooted name) (path-join root name))
+21
+22
(define greens (list 0))
+23
(define reds (list 0))
+24
+25
(define (arm name thunk)
+26
(guard (e (#t (set-car! reds (+ (car reds) 1))
+27
(println "F3B_ARM_~a_RED: raised: ~a" name
+28
(if (exception? e) (exception-message e) e))))
+29
(thunk)
+30
(set-car! greens (+ (car greens) 1))
+31
(println "F3B_ARM_~a_GREEN" name)))
+32
+33
(define (assert-true label v)
+34
(unless v (error (format "assertion failed: ~a" label))))
+35
(define (assert-refusal label r reason)
+36
(assert-true (format "~a: expected a refusal, got ~a" label r) (txn-refusal? r))
+37
(unless (eq? (txn-refusal-reason r) reason)
+38
(error (format "~a: expected reason ~a, got ~a: ~a" label reason
+39
(txn-refusal-reason r) (txn-refusal-message r)))))
+40
(define (assert-sop label r)
+41
(when (txn-refusal? r)
+42
(error (format "~a: refused ~a: ~a" label (txn-refusal-reason r) (txn-refusal-message r))))
+43
(assert-true (format "~a: expected a structural result" label) (sop-result? r)))
+44
(define (assert-txn label r)
+45
(when (txn-refusal? r)
+46
(error (format "~a: refused ~a: ~a" label (txn-refusal-reason r) (txn-refusal-message r))))
+47
(assert-true (format "~a: expected a result" label) (txn-result? r)))
+48
+49
(when (equal? (getenv "F3B_PROBE_SELFTEST") "1")
+50
(arm "selftest" (lambda () (assert-true "1 = 2 must fail" (= 1 2))))
+51
(println "F3B_PROBE_SELFTEST_DONE red=~a" (car reds))
+52
(exit (if (= (car reds) 1) 0 1)))
+53
+54
(define svc (make-service (default-worker-argv lib-dir src-dir) default-limits))
+55
(define coord (make-coordinator root svc))
+56
(define bystander-hash (revision-of-file (rooted "bystander.md")))
+57
+58
;; ---------------------------------------------------------------------------
+59
;; b1-stand-down -- THE control: an ordinary single-file structural insert on
+60
;; an exact match succeeds, bytes outside the span identical.
+61
;; ---------------------------------------------------------------------------
+62
(arm "b1-stand-down"
+63
(lambda ()
+64
(let* ((pre (read-file-bytes (rooted "owner.sgd")))
+65
(r (folio-insert-node! coord (rooted "owner.sgd") "work" 'task
+66
(revision-of-file (rooted "owner.sgd"))
+67
id: "t-standdown" title: "Inserted by the control"
+68
status: 'ready priority: 'low
+69
body-text: "The stand-down control's body.")))
+70
(assert-txn "uncontended structural insert" r)
+71
(let* ((post (read-file-bytes (rooted "owner.sgd")))
+72
(start (txn-result-changed-span-start r)))
+73
(assert-true "the new task landed"
+74
(string-contains? (read-file-string (rooted "owner.sgd")) "t-standdown"))
+75
(assert-true "bytes before the splice are untouched"
+76
(let loop ((i 0))
+77
(or (>= i start)
+78
(and (= (bytevector-u8-ref post i) (bytevector-u8-ref pre i))
+79
(loop (+ i 1))))))))))
+80
+81
;; ---------------------------------------------------------------------------
+82
;; b2-structural-mismatch -- F3a's arm must not regress: stale structural
+83
;; requests refuse, never rebase.
+84
;; ---------------------------------------------------------------------------
+85
(arm "b2-structural-mismatch"
+86
(lambda ()
+87
(let ((stale (revision-of-file (rooted "owner.sgd"))))
+88
(write-file-string (rooted "owner.sgd")
+89
(string-append (read-file-string (rooted "owner.sgd")) "\n"))
+90
(assert-refusal "stale insert"
+91
(folio-insert-node! coord (rooted "owner.sgd") "work" 'task stale
+92
id: "t-nope" title: "N" status: 'ready)
+93
'structural-revision-mismatch)
+94
(assert-refusal "stale delete"
+95
(folio-delete-node! coord (rooted "owner.sgd") "t-lonely" stale)
+96
'structural-revision-mismatch)
+97
(assert-refusal "stale rename"
+98
(folio-rename-id! coord "t-lonely" "t-fresh" stale)
+99
'structural-revision-mismatch)
+100
(assert-refusal "stale body edit"
+101
(folio-edit-body! coord (rooted "owner.sgd") "t-lonely" "@p{x}" stale)
+102
'structural-revision-mismatch))))
+103
+104
;; ---------------------------------------------------------------------------
+105
;; b3-recovery-completes -- a VALIDATED manifest (constructed through the same
+106
;; API the engine calls, i.e. the exact bytes a crash leaves after the commit
+107
;; point) is COMPLETED by the next transaction, and the completion is the
+108
;; state the interrupted transaction intended -- checked by content.
+109
;; ---------------------------------------------------------------------------
+110
(arm "b3-recovery-completes"
+111
(lambda ()
+112
(let* ((target (rooted "ref-one.sgd"))
+113
(pre-rev (revision-of-file target))
+114
(post-src (string-replace (read-file-string target)
+115
"one site" "one COMPLETED site"))
+116
(temp ".folio-staging-ref-one.sgd.b3test")
+117
(backup ".folio-backup-ref-one.sgd.b3test"))
+118
(write-file-string (rooted temp) post-src)
+119
(write-file-bytes (rooted backup) (read-file-bytes target))
+120
(let ((post-rev (revision-of-file (rooted temp))))
+121
(write-transaction-manifest! root "b3test" "probe-op"
+122
(list (mentry "ref-one.sgd" pre-rev temp post-rev post-rev backup pre-rev)))
+123
;; The next transaction must complete it first and report doing so.
+124
(let ((r (folio-insert-node! coord (rooted "owner.sgd") "work" 'update
+125
(revision-of-file (rooted "owner.sgd"))
+126
timestamp: "2026-08-25" id: "u-b3" body-text: "After recovery.")))
+127
(assert-txn "the transaction after the crash runs" r)
+128
(assert-true "recovery reported the completion"
+129
(let loop ((rest (txn-result-recovery-actions r)))
+130
(cond ((null? rest) #f)
+131
((string-contains? (car rest) "COMPLETED") #t)
+132
(else (loop (cdr rest))))))
+133
(assert-true "the completed content is the INTENDED content"
+134
(string-contains? (read-file-string target) "one COMPLETED site"))
+135
(assert-true "manifest cleared" (not (file-exists? (rooted ".folio-transaction-manifest"))))
+136
(assert-true "backup cleared" (not (file-exists? (rooted backup)))))))))
+137
+138
;; ---------------------------------------------------------------------------
+139
;; b4-recovery-rolls-back -- staged temps and backups WITHOUT a manifest are
+140
;; pre-commit debris: nothing was ever renamed, and the next transaction
+141
;; sweeps them and says so. This is the "unvalidated" branch, and the
+142
;; DISCRIMINATOR is the manifest's absence -- by construction there is no
+143
;; such thing as an unvalidated manifest on disk.
+144
;; ---------------------------------------------------------------------------
+145
(arm "b4-recovery-rolls-back"
+146
(lambda ()
+147
(let ((victim-rev (revision-of-file (rooted "ref-two.sgd"))))
+148
(write-file-string (rooted ".folio-staging-ref-two.sgd.b4") "half-staged content")
+149
(write-file-string (rooted ".folio-backup-ref-two.sgd.b4") "backup content")
+150
(let ((r (folio-insert-node! coord (rooted "owner.sgd") "work" 'update
+151
(revision-of-file (rooted "owner.sgd"))
+152
timestamp: "2026-08-25" id: "u-b4" body-text: "After rollback.")))
+153
(assert-txn "the transaction after the pre-commit crash runs" r)
+154
(assert-true "the rollback was reported"
+155
(let loop ((rest (txn-result-recovery-actions r)))
+156
(cond ((null? rest) #f)
+157
((string-contains? (car rest) "ROLLED BACK") #t)
+158
(else (loop (cdr rest))))))
+159
(assert-true "debris swept"
+160
(and (not (file-exists? (rooted ".folio-staging-ref-two.sgd.b4")))
+161
(not (file-exists? (rooted ".folio-backup-ref-two.sgd.b4")))))
+162
(assert-true "the target was never touched"
+163
(equal? (revision-of-file (rooted "ref-two.sgd")) victim-rev))))))
+164
+165
;; ---------------------------------------------------------------------------
+166
;; b5-rename -- N referrers across M files: all rewritten, or none.
+167
;; ---------------------------------------------------------------------------
+168
(arm "b5-rename"
+169
(lambda ()
+170
(let ((r (folio-rename-id! coord "t-target" "t-retargeted"
+171
(revision-of-file (rooted "owner.sgd")))))
+172
(assert-sop "the 3-file rename publishes" r)
+173
(assert-true "three files in the result" (= (length (sop-result-files r)) 3))
+174
(let ((all (string-append (read-file-string (rooted "owner.sgd"))
+175
(read-file-string (rooted "ref-one.sgd"))
+176
(read-file-string (rooted "ref-two.sgd")))))
+177
(assert-true "closure: zero old-id residue anywhere"
+178
(not (string-contains? all "t-target\"")))
+179
(assert-true "the declaration moved"
+180
(string-contains? (read-file-string (rooted "owner.sgd")) "@task[\"t-retargeted\""))
+181
(assert-true "both ref-two sites moved"
+182
(not (string-contains? (read-file-string (rooted "ref-two.sgd")) "t-target"))))
+183
(assert-true "no manifest or debris left"
+184
(not (file-exists? (rooted ".folio-transaction-manifest")))))))
+185
+186
;; ---------------------------------------------------------------------------
+187
;; b6-rename-refusals -- each guard with its own reason.
+188
;; ---------------------------------------------------------------------------
+189
(arm "b6-rename-refusals"
+190
(lambda ()
+191
(let ((rev (revision-of-file (rooted "owner.sgd"))))
+192
;; A referrer the semantic parser cannot see IS FOUND by the raw pass.
+193
(assert-refusal "raw-only prose referrer refuses the whole rename"
+194
(folio-rename-id! coord "t-prosebound" "t-fresh" rev)
+195
'unrewritable-reference)
+196
(assert-true "the prose file is untouched"
+197
(string-contains? (read-file-string (rooted "rawonly.sgd")) "t-prosebound"))
+198
;; Markdown occurrence refuses by policy.
+199
(write-file-string (rooted "legacy.md") "# Legacy\nSee t-lonely.\n")
+200
(assert-refusal "markdown occurrence refuses"
+201
(folio-rename-id! coord "t-lonely" "t-fresh"
+202
(revision-of-file (rooted "owner.sgd")))
+203
'unrewritable-reference)
+204
(delete-file (rooted "legacy.md"))
+205
(assert-refusal "taken target refuses"
+206
(folio-rename-id! coord "t-lonely" "t-mover"
+207
(revision-of-file (rooted "owner.sgd")))
+208
'rename-target-taken)
+209
(assert-refusal "unknown id refuses"
+210
(folio-rename-id! coord "t-nonesuch" "t-fresh"
+211
(revision-of-file (rooted "owner.sgd")))
+212
'unknown-node))))
+213
+214
;; ---------------------------------------------------------------------------
+215
;; b7-delete -- the referrer discipline, then the clean path.
+216
;; ---------------------------------------------------------------------------
+217
(arm "b7-delete"
+218
(lambda ()
+219
;; t-mover is referenced from ref-two.sgd -> refuse.
+220
(assert-refusal "deleting a referenced node refuses"
+221
(folio-delete-node! coord (rooted "owner.sgd") "t-mover"
+222
(revision-of-file (rooted "owner.sgd")))
+223
'node-referenced)
+224
;; t-lonely has no referrers -> deletes.
+225
(let ((r (folio-delete-node! coord (rooted "owner.sgd") "t-lonely"
+226
(revision-of-file (rooted "owner.sgd")))))
+227
(assert-txn "unreferenced delete publishes" r)
+228
(assert-true "the node is gone"
+229
(not (string-contains? (read-file-string (rooted "owner.sgd")) "t-lonely"))))))
+230
+231
;; ---------------------------------------------------------------------------
+232
;; b8-move -- cross-file with reference re-addressing, all in one manifest
+233
;; transaction.
+234
;; ---------------------------------------------------------------------------
+235
(arm "b8-move"
+236
(lambda ()
+237
(let ((r (folio-move-node! coord (rooted "owner.sgd") "t-mover"
+238
(rooted "dest.sgd") "incoming"
+239
(revision-of-file (rooted "owner.sgd"))
+240
expected-dest-revision: (revision-of-file (rooted "dest.sgd")))))
+241
(assert-sop "the cross-file move publishes" r)
+242
(assert-true "source lost it"
+243
(not (string-contains? (read-file-string (rooted "owner.sgd")) "t-mover")))
+244
(assert-true "dest gained it"
+245
(string-contains? (read-file-string (rooted "dest.sgd")) "t-mover"))
+246
(assert-true "the referrer was re-addressed to the destination document"
+247
(string-contains? (read-file-string (rooted "ref-two.sgd"))
+248
"document: \"topics/f3b-dest\" node: \"t-mover\""))
+249
(assert-true "no manifest left"
+250
(not (file-exists? (rooted ".folio-transaction-manifest")))))))
+251
+252
;; ---------------------------------------------------------------------------
+253
;; b9-scoped-out -- deliberate walls say so in those words.
+254
;; ---------------------------------------------------------------------------
+255
(arm "b9-scoped-out"
+256
(lambda ()
+257
(let ((r (folio-delete-node! coord (rooted "owner.sgd") "work"
+258
(revision-of-file (rooted "owner.sgd")))))
+259
(assert-refusal "container delete" r 'scoped-out)
+260
(assert-true "the refusal says SCOPED OUT, not unsupported"
+261
(string-contains? (txn-refusal-message r) "SCOPED OUT")))
+262
(assert-true "container insert raises with the scope ruling"
+263
(string? (guard (e (#t (if (exception? e) (exception-message e) "raised")))
+264
(folio-insert-node! coord (rooted "owner.sgd") "work" 'phase
+265
(revision-of-file (rooted "owner.sgd"))
+266
id: "p-x" title: "P" status: 'active)
+267
#f)))))
+268
+269
;; ---------------------------------------------------------------------------
+270
;; b10-invalid-readonly -- structural ops on an invalid document refuse it as
+271
;; read-only; a malformed document cannot kill the coordinator.
+272
;; ---------------------------------------------------------------------------
+273
(arm "b10-invalid-readonly"
+274
(lambda ()
+275
(let ((r (folio-insert-node! coord (rooted "broken.sgd") "topics/f3a-broken" 'update
+276
(revision-of-file (rooted "broken.sgd"))
+277
timestamp: "2026-08-25" id: "u-b10a")))
+278
(assert-refusal "structural op on an invalid document" r 'document-invalid))
+279
;; The coordinator survives it and the next ordinary op works.
+280
(assert-txn "the coordinator is alive after the malformed encounter"
+281
(folio-insert-node! coord (rooted "owner.sgd") "work" 'update
+282
(revision-of-file (rooted "owner.sgd"))
+283
timestamp: "2026-08-25" id: "u-b10b" body-text: "Still alive."))))
+284
+285
;; ---------------------------------------------------------------------------
+286
;; b11-body-replace -- the Q1 door end to end.
+287
;; ---------------------------------------------------------------------------
+288
(arm "b11-body-replace"
+289
(lambda ()
+290
(let ((r (folio-edit-body! coord (rooted "owner.sgd") "t-prosebound"
+291
"\n@p{A replaced prose body.}\n"
+292
(revision-of-file (rooted "owner.sgd")))))
+293
(assert-txn "prose body replaces on exact match" r)
+294
(assert-true "the body landed"
+295
(string-contains? (read-file-string (rooted "owner.sgd"))
+296
"A replaced prose body.")))))
+297
+298
;; ---------------------------------------------------------------------------
+299
;; b12-bystander -- the Markdown bystander's hash is pinned across the WHOLE
+300
;; battery: every arm above ran against this root, and none may have touched
+301
;; it.
+302
;; ---------------------------------------------------------------------------
+303
(arm "b12-bystander"
+304
(lambda ()
+305
(assert-true "bystander.md untouched by the entire battery"
+306
(equal? (revision-of-file (rooted "bystander.md")) bystander-hash))))
+307
+308
(println "F3B_PROBE_DONE arms=~a green=~a red=~a"
+309
(+ (car greens) (car reds)) (car greens) (car reds))
+310
(exit (if (= (car reds) 0) 0 1))
test/fixtures/f3b/broken.sgdadded
@@ -0,0 +1,9 @@
+1
#lang folio/doc
+2
@note["topics/f3a-broken" title: "Deliberately invalid"]
+3
+4
@task["t-broken" title: "An unterminated argument group follows" status: 'active
+5
@p{The task's argument group above never closes, so this document carries a
+6
LOCATED parse error and every transaction against it must refuse it as
+7
read-only.}
+8
+9
@end note
test/fixtures/f3b/bystander.mdadded
@@ -0,0 +1,4 @@
+1
# Bystander
+2
+3
A legacy Markdown note that no F3b operation may ever touch. It carries no
+4
node ids at all; the gate pins its hash across every arm.
test/fixtures/f3b/dest.sgdadded
@@ -0,0 +1,8 @@
+1
#lang folio/doc
+2
@note["topics/f3b-dest" title: "Move destination"]
+3
+4
@section["Incoming" id: "incoming"]{
+5
@p{The landing zone for moved work items.}
+6
}
+7
+8
@end note
test/fixtures/f3b/owner.sgdadded
@@ -0,0 +1,27 @@
+1
#lang folio/doc
+2
@note["topics/f3b-owner" title: "F3b owner" tags: '("f3b")]
+3
+4
@p{The owning document. Prose here deliberately repeats the word target and
+5
the digits 2026 but never any node id.}
+6
+7
@section["Work" id: "work"]{
+8
+9
@task["t-target" title: "The rename target" status: 'active priority: 'high]{
+10
@p{Referenced from two other documents.}
+11
}
+12
+13
@task["t-lonely" title: "Referenced by nobody" status: 'ready priority: 'low]{
+14
@p{Safe to delete.}
+15
}
+16
+17
@task["t-mover" title: "The move subject" status: 'ready priority: 'medium]{
+18
@p{Moves between documents in the cross-file arm.}
+19
}
+20
+21
@task["t-prosebound" title: "Discussed in prose elsewhere" status: 'ready priority: 'low]{
+22
@p{rawonly.sgd mentions this task's id in prose, so renaming it must refuse.}
+23
}
+24
+25
}
+26
+27
@end note
test/fixtures/f3b/rawonly.sgdadded
@@ -0,0 +1,7 @@
+1
#lang folio/doc
+2
@note["topics/f3b-rawonly" title: "Raw-only referrer"]
+3
+4
@p{This prose names t-prosebound with no rewritable span, which is exactly
+5
what the rename guard must catch. It never names any other id.}
+6
+7
@end note
test/fixtures/f3b/ref-one.sgdadded
@@ -0,0 +1,8 @@
+1
#lang folio/doc
+2
@note["topics/f3b-ref-one" title: "Referrer with one site"]
+3
+4
@p{One semantic reference lives here.}
+5
+6
@related[document: "topics/f3b-owner" node: "t-target" kind: 'depends-on]{the target}
+7
+8
@end note
test/fixtures/f3b/ref-two.sgdadded
@@ -0,0 +1,10 @@
+1
#lang folio/doc
+2
@note["topics/f3b-ref-two" title: "Referrer with two sites"]
+3
+4
@transclude[document: "topics/f3b-owner" node: "t-target"]{}
+5
+6
@related[document: "topics/f3b-owner" node: "t-target" kind: 'mentions]{again}
+7
+8
@related[document: "topics/f3b-owner" node: "t-mover" kind: 'depends-on]{the mover}
+9
+10
@end note