Commit14c99b80Recorded25 Aug 2026Repositoryfolio

F3b: close the review's HIGH + two MEDIUMs, with a door inventory

Message

The reviewer found a second containment escape in this component, one phase after F3a's: recovery renamed through untrusted manifest paths with no containment check, while the forward door refuses the identical ../ escape. The leader required a DOOR INVENTORY, not a point fix -- every coordinator path that writes/renames/deletes a path NOT from a freshly-validated request, with each one's containment status. The table is in the task note; every escaping door was in recovery (trusting manifest bytes) plus the sweep's depth gap. All forward doors already resolve through require-contained-path.

HIGH (reproduced by the author before fixing -- outside victim changed): recover-complete! now runs every manifest target/temp/backup through a real-path containment check FIRST, before proving anything or touching a file; a path escaping the root is a corrupt/hostile manifest and joins the refuse-to-a-human class -- its extent cannot be established, so nothing is renamed or deleted. Verified BEHAVIOURALLY (the refusal fires and the outside file is untouched) because an intervening stale build made a source-only check pass silently -- the estate's own trap; the behavioural probe is what cut through it.

MEDIUM 1: sweep-debris! walks the full tree (folio slugs nest -- debris two dirs deep is the normal shape here), skipping directory symlinks and deleting only files whose real path is under the real root.

MEDIUM 2: discovery drops dot-prefixed names without a census record, so population-guard reported COMPLETE over a root with a .hidden note -- the guard failing at its own job. Rather than change discovery's shared dotfile skip (it correctly hides this coordinator's .folio-* artifacts), the guard independently scans the root's top level for any non-artifact dot-entry and refuses every root-wide destructive op on it.

Battery grows to 15 arms: b13 (escaping manifest refused to a human, outside file untouched, manifest kept), b14 (depth-2 debris swept), b15 (dot-directory refuses delete AND rename, artifacts do not trip it). 15/15 green.

Changed
 src/folio/doc/manifest.sgl   | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
 src/folio/doc/structural.sgl |  46 +++++++++++++++++++++++++++++++++++++++++-----
 test/f3b-probe.sgl           |  93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/gate-f3b.sh             |   6 +++---
 4 files changed, 220 insertions(+), 36 deletions(-)
Diff
src/folio/doc/manifest.sglmodified
@@ -149,6 +149,39 @@
149
(define (hash-of-file path)
150
(guard (e (#t #f)) (revision-of-file path)))
151
+152
;; THE MANIFEST IS UNTRUSTED INPUT AT RECOVERY TIME. A crashed writer --
+153
;; or anything that can drop a file into the root -- authored its fields,
+154
;; and recovery renames and deletes paths built from them. The forward
+155
;; engine only ever writes root-relative in-root paths, but recovery must
+156
;; not TRUST that: a manifest whose target/temp/backup contains `../`
+157
;; makes recovery rename OUTSIDE the root (reviewer HIGH, 2026-08-25,
+158
;; reproduced -- `outside victim changed: #t`). Every manifest path is
+159
;; resolved through real-path containment before it is acted on, exactly
+160
;; as the forward doors resolve a request's path; anything escaping is a
+161
;; corrupt/hostile manifest and joins the refuse-to-a-human class -- the
+162
;; extent of a hostile recovery cannot be established, so nothing is
+163
;; renamed or deleted. The check is on the PARENT dir (where the rename
+164
;; lands / the file sits), matching `require-contained-path`'s domain.
+165
(define (path-contained? root rel)
+166
(let ((root-real (realpath root)))
+167
(and root-real
+168
(let* ((joined (path-join root-real rel))
+169
(parent-real (realpath (path-dirname joined))))
+170
(and parent-real
+171
(or (equal? parent-real root-real)
+172
(string-starts-with? parent-real
+173
(string-append root-real "/"))))))))
+174
+175
(define (manifest-paths-contained? root entries)
+176
(let loop ((rest entries))
+177
(cond
+178
((null? rest) #t)
+179
((and (path-contained? root (mentry-target (car rest)))
+180
(path-contained? root (mentry-temp (car rest)))
+181
(path-contained? root (mentry-backup (car rest))))
+182
(loop (cdr rest)))
+183
(else #f))))
+184
185
;;; RECOVERY. Runs inside the root lock (the coordinator calls it before
186
;;; accepting any mutation). Returns a LIST OF ACTION STRINGS -- '() when
187
;;; there was nothing to recover -- which the coordinator threads into the
@@ -187,6 +220,15 @@
220
(let ((txn-id (list-ref parsed 0))
221
(op-label (list-ref parsed 1))
222
(entries (list-ref parsed 2)))
+223
;; Containment FIRST, before proving anything else and before
+224
;; touching any file: a path that escapes the root cannot be
+225
;; safely renamed OR rolled back, and it is the signature of a
+226
;; corrupt or hostile manifest, so it is refused to a human like
+227
;; any other unprovable state.
+228
(unless (manifest-paths-contained? root entries)
+229
(error (format
+230
"recovery: the transaction manifest at ~a/~a names a path outside the root. A manifest is written only with in-root relative paths, so this one is corrupt or hostile; recovery will not rename or delete along it. A human must inspect the manifest and the .folio-backup-* files."
+231
root manifest-file-name)))
232
;; First pass: PROVE every entry is finishable before touching
233
;; anything -- completion is all-or-refuse, not best-effort.
234
(for-each
@@ -229,32 +271,45 @@
271
actions))))
272
273
;;; Sweep staged temps and backups from a transaction that never reached
−232
;;; its commit point. Only THIS MODULE'S prefixes, only at the root's top
−233
;;; level and one directory deep (where notes live); nothing else is
−234
;;; touched, and every removal is reported.
+274
;;; its commit point. Only THIS MODULE'S prefixes; every removal reported.
+275
;;;
+276
;;; DEPTH IS UNBOUNDED, matching discovery: folio slugs nest routinely
+277
;;; (topics/, archive/tasks/), so a cross-file transaction stages debris
+278
;;; beside notes two and three directories deep, and a one-level sweep
+279
;;; leaves it to accumulate silently (reviewer MEDIUM, 2026-08-25). The
+280
;;; walk skips directory symlinks -- the same cycle hazard discovery
+281
;;; refuses to descend -- so it cannot loop, and it deletes ONLY a file
+282
;;; whose real path is under the real root, so a symlinked debris-named
+283
;;; file pointing outside cannot make the sweep delete out-of-root.
284
(define (sweep-debris! root)
−236
(let ((actions '()))
−237
(for-each
−238
(lambda (dir)
−239
(for-each
−240
(lambda (name)
−241
(when (or (string-starts-with? name staging-prefix)
−242
(string-starts-with? name backup-prefix))
−243
(let ((full (path-join dir name)))
−244
(guard (e (#t #f)) (delete-file full))
−245
(set! actions
−246
(cons (format "recovery: ROLLED BACK (swept pre-commit debris ~a -- no manifest existed, so no target was ever renamed)"
−247
full)
−248
actions)))))
−249
(guard (e (#t '())) (directory-list dir))))
−250
(cons root
−251
(filter (lambda (p) (directory-exists? p))
−252
(map (lambda (n) (path-join root n))
−253
(guard (e (#t '())) (directory-list root))))))
−254
(reverse actions)))
−255
−256
(define (filter pred xs)
−257
(let loop ((rest xs) (out '()))
−258
(cond ((null? rest) (reverse out))
−259
((pred (car rest)) (loop (cdr rest) (cons (car rest) out)))
−260
(else (loop (cdr rest) out)))))))
+285
(let ((root-real (realpath root))
+286
(actions '()))
+287
(let walk ((dirs (list root)))
+288
(unless (null? dirs)
+289
(let ((dir (car dirs)))
+290
(let loop ((names (guard (e (#t '())) (or (directory-list dir) '())))
+291
(subdirs '()))
+292
(cond
+293
((null? names)
+294
(walk (append subdirs (cdr dirs))))
+295
(else
+296
(let ((full (path-join dir (car names))))
+297
(cond
+298
((and (directory? full) (not (symlink? full)))
+299
(loop (cdr names) (cons full subdirs)))
+300
((and (not (directory? full))
+301
(or (string-starts-with? (car names) staging-prefix)
+302
(string-starts-with? (car names) backup-prefix)))
+303
(let ((parent-real (realpath (path-dirname full))))
+304
(when (and root-real parent-real
+305
(or (equal? parent-real root-real)
+306
(string-starts-with? parent-real
+307
(string-append root-real "/"))))
+308
(guard (e (#t #f)) (delete-file full))
+309
(set! actions
+310
(cons (format "recovery: ROLLED BACK (swept pre-commit debris ~a -- no manifest existed, so no target was ever renamed)"
+311
full)
+312
actions))))
+313
(loop (cdr names) subdirs))
+314
(else (loop (cdr names) subdirs))))))))))
+315
(reverse actions)))))
src/folio/doc/structural.sglmodified
@@ -41,6 +41,7 @@
41
(folio doc inspect)
42
(folio doc discovery)
43
(folio doc manifest)
+44
(folio doc lock)
45
(folio doc coordinator)
46
(folio doc transaction))
47
(export
@@ -154,7 +155,7 @@
155
;; outside the document population the ruled independent pass covers.
156
;; Returns #f when the census is trustworthy, or a refusal naming what
157
;; is not.
−157
(define (population-guard census)
+158
(define (population-guard coord census)
159
(let ((unwalkable
160
(append
161
(map (lambda (d) (list d 'unreadable-directory))
@@ -166,7 +167,20 @@
167
((eq? (skip-reason (car rest)) 'symlinked-directory)
168
(loop (cdr rest)
169
(cons (list (skip-path (car rest)) 'symlinked-directory) out)))
−169
(else (loop (cdr rest) out)))))))
+170
(else (loop (cdr rest) out))))
+171
;; DISCOVERY DROPS DOT-PREFIXED NAMES WITHOUT A CENSUS RECORD
+172
;; (entries-of skips them, files AND directories), so a note
+173
;; in `.hidden/` or a `.foo.sgd` is invisible and the census
+174
;; still reads COMPLETE -- the guard failing at its own job
+175
;; (reviewer MEDIUM, 2026-08-25). discovery's skip is shared
+176
;; with F2a/F2b and correctly hides this coordinator's OWN
+177
;; `.folio-*` artifacts; so rather than change it, the guard
+178
;; independently scans the root's top level for any dot-entry
+179
;; that is NOT one of those artifacts and treats it as an
+180
;; unwalked location. Top level is where a `.hidden` dir or a
+181
;; dot-note would sit; anything under it is unreachable by
+182
;; discovery regardless.
+183
(dot-intruders coord census))))
184
(if (null? unwalkable)
185
#f
186
(%txn-refuse 'population-unprovable
@@ -174,6 +188,28 @@
188
(length unwalkable) unwalkable)
189
retryable: #t))))
190
+191
;; The coordinator's own well-known artifacts, which discovery is RIGHT
+192
;; to hide. A dot-entry matching one of these prefixes is expected; any
+193
;; other dot-entry is a note or directory discovery silently dropped.
+194
(define (artifact-dot-name? name)
+195
(or (equal? name coordination-file-name)
+196
(equal? name transaction-manifest-name)
+197
(string-starts-with? name staging-prefix)
+198
(string-starts-with? name backup-prefix)
+199
(string-starts-with? name ".folio-coordination-probe-")))
+200
+201
(define (dot-intruders coord census)
+202
(let ((root (coordinator-root coord)))
+203
(let loop ((names (guard (e (#t '())) (or (directory-list root) '())))
+204
(out '()))
+205
(cond
+206
((null? names) (reverse out))
+207
((and (string-starts-with? (car names) ".")
+208
(not (artifact-dot-name? (car names))))
+209
(loop (cdr names)
+210
(cons (list (path-join root (car names)) 'hidden-unwalked) out)))
+211
(else (loop (cdr names) out))))))
+212
213
(define (root-population coord)
214
;; Returns (census . entries) where each entry is
215
;; (list rel-path abs-path format bytes-or-#f). Bytes are read for
@@ -458,7 +494,7 @@
494
(let* ((doomed (cons node-id (%txn-subtree-declared-ids node)))
495
(doomed (filter string? doomed))
496
(population (root-population coord))
−461
(guard-refusal (population-guard (car population)))
+497
(guard-refusal (population-guard coord (car population)))
498
(found (find-referrers coord (cdr population) doomed))
499
(semantic (list-ref found 0))
500
(raw-only (list-ref found 1))
@@ -596,7 +632,7 @@
632
(let* ((population (root-population coord))
633
(census (car population))
634
(entries (cdr population))
−599
(guard-refusal (population-guard census)))
+635
(guard-refusal (population-guard coord census)))
636
(if guard-refusal
637
guard-refusal
638
;; 1. The NEW id must be completely fresh -- no boundary occurrence
@@ -1034,7 +1070,7 @@
1070
;; discipline as rename.
1071
(let* ((root (coordinator-root coord))
1072
(population (root-population coord))
−1037
(guard-refusal (population-guard (car population)))
+1073
(guard-refusal (population-guard coord (car population)))
1074
(entries (cdr population))
1075
(found (find-referrers coord entries moved-ids))
1076
(semantic (list-ref found 0))
test/f3b-probe.sglmodified
@@ -322,6 +322,99 @@
322
(assert-true "bystander.md untouched by the entire battery"
323
(equal? (revision-of-file (rooted "bystander.md")) bystander-hash))))
324
+325
;; ---------------------------------------------------------------------------
+326
;; b13-recovery-containment -- the reviewer's HIGH: a manifest whose target
+327
;; escapes the root via `..` must REFUSE TO A HUMAN and rename nothing.
+328
;; Recovery treats the on-disk manifest as UNTRUSTED input.
+329
;; ---------------------------------------------------------------------------
+330
(arm "b13-recovery-containment"
+331
(lambda ()
+332
;; A private escape root so the outside file is disposable.
+333
(let* ((base (path-dirname root))
+334
(esc-root (path-join base "b13-esc-root"))
+335
(esc-out (path-join base "b13-esc-outside"))
+336
(victim (path-join esc-out "victim.sgd")))
+337
(unless (directory-exists? esc-root) (make-directory esc-root))
+338
(unless (directory-exists? esc-out) (make-directory esc-out))
+339
(write-file-bytes victim (read-file-bytes (rooted "ref-one.sgd")))
+340
(write-file-string (path-join esc-root ".folio-staging-esc.tmp")
+341
"PAYLOAD: recovery wrote outside the locked root\n")
+342
(let ((tr (revision-of-file (path-join esc-root ".folio-staging-esc.tmp")))
+343
(vr (revision-of-file victim)))
+344
(write-file-bytes (path-join esc-root ".folio-backup-esc") (read-file-bytes victim))
+345
(write-transaction-manifest! esc-root "b13" "rename-id"
+346
(list (mentry "../b13-esc-outside/victim.sgd" vr
+347
".folio-staging-esc.tmp" tr tr ".folio-backup-esc" vr)))
+348
(let* ((victim-before (revision-of-file victim))
+349
(coord2 (make-coordinator esc-root svc))
+350
(raised (guard (e (#t (if (exception? e) (exception-message e) "raised")))
+351
(folio-insert-node! coord2 (path-join esc-root "x.sgd") "c"
+352
'update "sha256:0000000000000000000000000000000000000000000000000000000000000000"
+353
timestamp: "2026-08-25" id: "u-z")
+354
#f)))
+355
(assert-true "recovery refused the escaping manifest to a human"
+356
(and (string? raised) (string-contains? raised "human")))
+357
(assert-true "the file outside the root was NOT written"
+358
(equal? (revision-of-file victim) victim-before))
+359
(assert-true "the manifest is kept for the human"
+360
(file-exists? (path-join esc-root ".folio-transaction-manifest"))))))))
+361
+362
;; ---------------------------------------------------------------------------
+363
;; b14-sweep-depth -- the reviewer's MEDIUM 1: pre-commit debris nested TWO
+364
;; directories deep is swept by the rollback branch, not left to accumulate.
+365
;; ---------------------------------------------------------------------------
+366
(arm "b14-sweep-depth"
+367
(lambda ()
+368
(let ((d0 (rooted ".folio-staging-b14-d0.tmp"))
+369
(d1 (rooted "sub14/.folio-staging-b14-d1.tmp"))
+370
(d2 (rooted "sub14/deep14/.folio-backup-b14-d2")))
+371
(unless (directory-exists? (rooted "sub14")) (make-directory (rooted "sub14")))
+372
(unless (directory-exists? (rooted "sub14/deep14")) (make-directory (rooted "sub14/deep14")))
+373
(write-file-string d0 "d0") (write-file-string d1 "d1") (write-file-string d2 "d2")
+374
;; No manifest exists -> the next transaction's recovery rolls back.
+375
(let ((r (folio-insert-node! coord (rooted "dest.sgd") "incoming" 'update
+376
(revision-of-file (rooted "dest.sgd"))
+377
timestamp: "2026-08-25" id: "u-b14")))
+378
(assert-txn "the transaction runs" r)
+379
(assert-true "depth-0 debris swept" (not (file-exists? d0)))
+380
(assert-true "depth-1 debris swept" (not (file-exists? d1)))
+381
(assert-true "depth-2 debris swept" (not (file-exists? d2)))))))
+382
+383
;; ---------------------------------------------------------------------------
+384
;; b15-dot-population -- the reviewer's MEDIUM 2: discovery drops dot-prefixed
+385
;; names without a census record, so a referrer in a dot-directory is unseen.
+386
;; The population guard must independently detect the dot-entry and refuse
+387
;; every root-wide destructive op (delete AND rename) rather than proceed
+388
;; against a population it cannot prove complete.
+389
;; ---------------------------------------------------------------------------
+390
(arm "b15-dot-population"
+391
(lambda ()
+392
;; A fresh, unreferenced node so the population guard (not unknown-node or
+393
;; node-referenced) is the reason under test.
+394
(assert-txn "seed a fresh target"
+395
(folio-insert-node! coord (rooted "owner.sgd") "work" 'task
+396
(revision-of-file (rooted "owner.sgd"))
+397
id: "t-b15" title: "Guard subject" status: 'ready))
+398
(unless (directory-exists? (rooted ".hidden15")) (make-directory (rooted ".hidden15")))
+399
(write-file-string (rooted ".hidden15/note.sgd")
+400
"#lang folio/doc\n@note[\"topics/h15\" title: \"H\"]\n@p{prose only, no referrer}\n@end note\n")
+401
(assert-refusal "delete refuses over a dot-directory population"
+402
(folio-delete-node! coord (rooted "owner.sgd") "t-b15"
+403
(revision-of-file (rooted "owner.sgd")))
+404
'population-unprovable)
+405
(assert-refusal "rename refuses over the same population"
+406
(folio-rename-id! coord "t-b15" "t-fresh15"
+407
(revision-of-file (rooted "owner.sgd")))
+408
'population-unprovable)
+409
;; And the coordinator's OWN dot-artifacts must NOT trip the guard: after
+410
;; removing the intruder, an ordinary op proceeds.
+411
(delete-file (rooted ".hidden15/note.sgd"))
+412
(delete-directory (rooted ".hidden15"))
+413
(assert-txn "an ordinary op proceeds once the dot-intruder is gone"
+414
(folio-insert-node! coord (rooted "owner.sgd") "work" 'update
+415
(revision-of-file (rooted "owner.sgd"))
+416
timestamp: "2026-08-25" id: "u-b15"))))
+417
418
(println "F3B_PROBE_DONE arms=~a green=~a red=~a"
419
(+ (car greens) (car reds)) (car greens) (car reds))
420
(exit (if (= (car reds) 0) 0 1))
test/gate-f3b.shmodified
@@ -77,8 +77,8 @@ fi
77
if want 2; then
78
r2="$work/b"; fresh_f3b_root "$r2"
79
F3B_ROOT="$r2" run_sgl test/f3b-probe.sgl >"$work/b.log" 2>&1 || fail "arm2: battery exited non-zero"
−80
grep -q "F3B_PROBE_DONE arms=12 green=12 red=0" "$work/b.log" \
−81
|| { fail "arm2: expected 12/12; got $(grep F3B_PROBE_DONE "$work/b.log" || echo none)"; \
+80
grep -q "F3B_PROBE_DONE arms=15 green=15 red=0" "$work/b.log" \
+81
|| { fail "arm2: expected 15/15; got $(grep F3B_PROBE_DONE "$work/b.log" || echo none)"; \
82
grep "_RED" "$work/b.log" >&2 || true; }
83
arm_summary 2
84
fi
@@ -201,7 +201,7 @@ fi
201
202
echo "---"
203
if [ "$fails" -eq 0 ]; then
−204
echo "F3B_GATE_GREEN arms_run=[$only_arms] f3b_arms=12 f3a_arms=22 kill_rounds=5 sha=$(git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null || echo unknown)"
+204
echo "F3B_GATE_GREEN arms_run=[$only_arms] f3b_arms=15 f3a_arms=22 kill_rounds=5 sha=$(git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null || echo unknown)"
205
exit 0
206
else
207
echo "F3B_GATE_RED failures=$fails arms_run=[$only_arms]"