AtlatestRepositoryfolio
1
;;; (folio doc manifest) -- the cross-file transaction manifest and its2
;;; crash recovery (F3b).3
;;;4
;;; D11: "Cross-file transactions stage and validate the complete revision set5
;;; while holding the same root lock, then retain a bounded filesystem6
;;; crash-recovery manifest until every rename completes ... After acquiring7
;;; the released lock, the next writer checks for an incomplete transaction8
;;; manifest and either completes the already-validated rename set or rolls it9
;;; back before accepting another mutation."10
;;;11
;;; THE DISCRIMINATOR IS PRESENCE, BY CONSTRUCTION. The manifest is written12
;;; ATOMICALLY (temp + rename) and ONLY AFTER every staged file has passed its13
;;; full validation; target renames begin only after the manifest exists on14
;;; disk. Therefore:15
;;;16
;;; manifest present => the rename set was ALREADY VALIDATED, and recovery17
;;; COMPLETES it (or refuses to a human where18
;;; completion cannot be proven safe);19
;;; manifest absent => no target file was ever renamed, and recovery20
;;; ROLLS BACK by sweeping this module's staged temps21
;;; and backups.22
;;;23
;;; There is deliberately NO re-validate-and-proceed path: nothing in this24
;;; module re-runs validation, because a manifest that exists IS the proof25
;;; validation happened. A recovery path that re-validated and proceeded26
;;; would be a different and much worse thing than one that finishes a27
;;; decided operation (leader ruling, 2026-08-25, on D11's "already-validated").28
;;;29
;;; WHAT THE MANIFEST IS NOT. Operational recovery metadata only -- never30
;;; authoritative knowledge state. Nothing reads a manifest to learn what a31
;;; note says; it is read once, inside the lock, to finish or undo an32
;;; interrupted publish, and then removed.33
;;;34
;;; FORMAT. One line `F3B_MANIFEST v1 <txn-id> <op-label>` then one line per35
;;; file: tab-separated `FILE <target> <pre-rev> <temp> <temp-hash> <post-rev>36
;;; <backup> <backup-hash>`, then the terminator line `COMPLETE`. A manifest37
;;; whose terminator is missing or whose lines do not parse is CORRUPT --38
;;; unreachable while POSIX rename is atomic, kept as a defensive branch --39
;;; and recovery REFUSES TO A HUMAN rather than guessing: renames may have40
;;; begun, and an unprovable state is reported, not repaired. Paths are41
;;; recorded root-relative so a root moved wholesale still recovers.42
;;;43
;;; DURABILITY, STATED HONESTLY: no fsync primitive exists on this toolchain,44
;;; so the ordering guarantees here are about rename ATOMICITY (a crashed45
;;; writer leaves whole files, never torn ones), not power-loss durability of46
;;; the metadata itself.47
(define-library (folio doc manifest)48
(import (sigil core)49
(sigil struct)50
(sigil string)51
(sigil list)52
(sigil fs)53
(sigil path)54
(sigil io)55
(folio doc revision))56
(export manifest-file-name57
staging-prefix backup-prefix58
mentry mentry? mentry-target mentry-pre-rev mentry-temp59
mentry-temp-hash mentry-post-rev mentry-backup mentry-backup-hash60
write-transaction-manifest! clear-transaction-manifest!61
read-transaction-manifest62
recover-root!)63
(begin65
;; One spelling, shared with (folio doc coordinator)'s check and the gate.66
(define manifest-file-name ".folio-transaction-manifest")67
(define staging-prefix ".folio-staging-")68
(define backup-prefix ".folio-backup-")70
(define-struct mentry-record71
(target) (pre-rev) (temp) (temp-hash) (post-rev) (backup) (backup-hash))73
(define (mentry target pre-rev temp temp-hash post-rev backup backup-hash)74
(mentry-record target: target pre-rev: pre-rev temp: temp75
temp-hash: temp-hash post-rev: post-rev76
backup: backup backup-hash: backup-hash))77
(define (mentry? v) (mentry-record? v))78
(define (mentry-target e) (mentry-record-target e))79
(define (mentry-pre-rev e) (mentry-record-pre-rev e))80
(define (mentry-temp e) (mentry-record-temp e))81
(define (mentry-temp-hash e) (mentry-record-temp-hash e))82
(define (mentry-post-rev e) (mentry-record-post-rev e))83
(define (mentry-backup e) (mentry-record-backup e))84
(define (mentry-backup-hash e) (mentry-record-backup-hash e))86
;;; Write the manifest ATOMICALLY: the temp is fully written first, the87
;;; rename is the commit point. Callers must call this only after every88
;;; entry's staged file passed validation -- presence means validated, and89
;;; this module cannot check that for them, so the ORDER OF CALLS in the90
;;; publisher is load-bearing and its gate arm plants exactly a reordering.91
(define (write-transaction-manifest! root txn-id op-label entries)92
(let* ((path (path-join root manifest-file-name))93
(temp (string-append path ".tmp"))94
(body (apply string-append95
(format "F3B_MANIFEST v1 ~a ~a~%" txn-id op-label)96
(append97
(map (lambda (e)98
(format "FILE\t~a\t~a\t~a\t~a\t~a\t~a\t~a~%"99
(mentry-target e) (mentry-pre-rev e)100
(mentry-temp e) (mentry-temp-hash e)101
(mentry-post-rev e)102
(mentry-backup e) (mentry-backup-hash e)))103
entries)104
(list "COMPLETE\n")))))105
(write-file-string temp body)106
(rename-file temp path)107
path))109
(define (clear-transaction-manifest! root)110
(let ((path (path-join root manifest-file-name)))111
(when (file-exists? path) (delete-file path))))113
;;; Parse the manifest. Returns (list txn-id op-label entries) or the114
;;; symbol 'corrupt. Absent is the CALLER's branch (file-exists?), so a115
;;; read here is always of something that claims to be a manifest.116
(define (read-transaction-manifest root)117
(let* ((path (path-join root manifest-file-name))118
(text (guard (e (#t #f)) (read-file-string path))))119
(if (not (string? text))120
'corrupt121
(let ((lines (string-split text "\n")))122
(if (or (null? lines)123
(not (string-starts-with? (car lines) "F3B_MANIFEST v1 ")))124
'corrupt125
(let* ((head (string-split (car lines) " "))126
(txn-id (if (>= (length head) 3) (list-ref head 2) #f))127
(op-label (if (>= (length head) 4) (list-ref head 3) #f)))128
(let loop ((rest (cdr lines)) (entries '()) (complete #f))129
(cond130
((null? rest)131
(if complete (list txn-id op-label (reverse entries)) 'corrupt))132
((equal? (car rest) "COMPLETE")133
(loop (cdr rest) entries #t))134
((equal? (car rest) "")135
(loop (cdr rest) entries complete))136
((string-starts-with? (car rest) "FILE\t")137
(let ((f (string-split (car rest) "\t")))138
(if (= (length f) 8)139
(loop (cdr rest)140
(cons (mentry (list-ref f 1) (list-ref f 2)141
(list-ref f 3) (list-ref f 4)142
(list-ref f 5) (list-ref f 6)143
(list-ref f 7))144
entries)145
complete)146
'corrupt)))147
(else 'corrupt)))))))))149
(define (hash-of-file path)150
(guard (e (#t #f)) (revision-of-file path)))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 forward155
;; engine only ever writes root-relative in-root paths, but recovery must156
;; 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 is159
;; resolved through real-path containment before it is acted on, exactly160
;; as the forward doors resolve a request's path; anything escaping is a161
;; corrupt/hostile manifest and joins the refuse-to-a-human class -- the162
;; extent of a hostile recovery cannot be established, so nothing is163
;; renamed or deleted. The check is on the PARENT dir (where the rename164
;; 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-real168
(let* ((joined (path-join root-real rel))169
(parent-real (realpath (path-dirname joined))))170
(and parent-real171
(or (equal? parent-real root-real)172
(string-starts-with? parent-real173
(string-append root-real "/"))))))))175
(define (manifest-paths-contained? root entries)176
(let loop ((rest entries))177
(cond178
((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))))185
;;; RECOVERY. Runs inside the root lock (the coordinator calls it before186
;;; accepting any mutation). Returns a LIST OF ACTION STRINGS -- '() when187
;;; there was nothing to recover -- which the coordinator threads into the188
;;; transaction's result so a self-heal is never silent. RAISES with a189
;;; human-directed message when the state cannot be proven safe to finish190
;;; or undo; it never guesses.191
;;;192
;;; Two branches, per the discriminator:193
;;;194
;;; COMPLETE (manifest present and parseable): every entry is finished195
;;; individually --196
;;; target already at post-rev -> already published, note it197
;;; target at pre-rev, temp hash matches -> rename the temp in198
;;; anything else -> REFUSE to a human: either199
;;; the temp is gone (cannot complete) or the target is at a THIRD200
;;; revision (an external edit raced the crash window; finishing would201
;;; clobber it, rolling back would clobber it, only a human knows).202
;;; Backups and the manifest are removed only after every entry finished.203
;;;204
;;; ROLLBACK (no manifest): nothing was ever renamed, so recovery is205
;;; sweeping THIS MODULE'S debris -- stale staging temps and backups from206
;;; a transaction that died before its commit point. Targets are not207
;;; touched (there is nothing to restore; they were never written).208
(define (recover-root! root)209
(let ((mpath (path-join root manifest-file-name)))210
(if (file-exists? mpath)211
(recover-complete! root)212
(sweep-debris! root))))214
(define (recover-complete! root)215
(let ((parsed (read-transaction-manifest root)))216
(when (eq? parsed 'corrupt)217
(error (format218
"recovery: the transaction manifest at ~a/~a does not parse. Renames may have begun and their extent cannot be established; refusing every mutation on this root until a human inspects the manifest, the .folio-backup-* files, and the targets."219
root manifest-file-name)))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 before224
;; touching any file: a path that escapes the root cannot be225
;; safely renamed OR rolled back, and it is the signature of a226
;; corrupt or hostile manifest, so it is refused to a human like227
;; any other unprovable state.228
(unless (manifest-paths-contained? root entries)229
(error (format230
"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 touching233
;; anything -- completion is all-or-refuse, not best-effort.234
(for-each235
(lambda (e)236
(let* ((target (path-join root (mentry-target e)))237
(cur (hash-of-file target)))238
(cond239
((equal? cur (mentry-post-rev e)) #t)240
((and (equal? cur (mentry-pre-rev e))241
(equal? (hash-of-file (path-join root (mentry-temp e)))242
(mentry-temp-hash e)))243
#t)244
(else245
(error (format246
"recovery: cannot prove ~a safe to complete (target at ~a; manifest expected pre ~a or post ~a; staged temp ~a). A human must finish or undo transaction ~a (~a); backups are the .folio-backup-* files named in the manifest."247
(mentry-target e) (or cur "unreadable")248
(mentry-pre-rev e) (mentry-post-rev e)249
(if (equal? (hash-of-file (path-join root (mentry-temp e)))250
(mentry-temp-hash e))251
"intact" "missing or altered")252
txn-id op-label))))))253
entries)254
;; Second pass: finish.255
(let ((actions256
(map (lambda (e)257
(let* ((target (path-join root (mentry-target e)))258
(cur (hash-of-file target)))259
(if (equal? cur (mentry-post-rev e))260
(format "recovery: ~a was already published by the interrupted transaction ~a" (mentry-target e) txn-id)261
(begin262
(rename-file (path-join root (mentry-temp e)) target)263
(format "recovery: COMPLETED the validated rename of ~a for interrupted transaction ~a (~a)" (mentry-target e) txn-id op-label)))))264
entries)))265
;; Only now is the metadata safe to drop.266
(for-each (lambda (e)267
(let ((b (path-join root (mentry-backup e))))268
(guard (x (#t #f)) (when (file-exists? b) (delete-file b)))))269
entries)270
(clear-transaction-manifest! root)271
actions))))273
;;; Sweep staged temps and backups from a transaction that never reached274
;;; its commit point. Only THIS MODULE'S prefixes; every removal reported.275
;;;276
;;; DEPTH IS UNBOUNDED, matching discovery: folio slugs nest routinely277
;;; (topics/, archive/tasks/), so a cross-file transaction stages debris278
;;; beside notes two and three directories deep, and a one-level sweep279
;;; leaves it to accumulate silently (reviewer MEDIUM, 2026-08-25). The280
;;; walk skips directory symlinks -- the same cycle hazard discovery281
;;; refuses to descend -- so it cannot loop, and it deletes ONLY a file282
;;; whose real path is under the real root, so a symlinked debris-named283
;;; file pointing outside cannot make the sweep delete out-of-root.284
(define (sweep-debris! root)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
(cond293
((null? names)294
(walk (append subdirs (cdr dirs))))295
(else296
(let ((full (path-join dir (car names))))297
(cond298
((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-real305
(or (equal? parent-real root-real)306
(string-starts-with? parent-real307
(string-append root-real "/"))))308
(guard (e (#t #f)) (delete-file full))309
(set! actions310
(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)))))