Commit393de070Recorded25 Aug 2026Repositoryfolio

F3b: crash recovery, the manifest, and body replacement

Message

manifest.sgl -- the cross-file transaction manifest and its recovery. THE DISCRIMINATOR IS PRESENCE, BY CONSTRUCTION: the manifest is written atomically (temp + rename) only after every staged file passed validation, and target renames begin only after it exists -- so a manifest on disk IS the proof the rename set was already validated, and D11's two recovery branches cannot be confused: present -> COMPLETE (each entry individually: already-published noted, pending renames finished from hash-verified temps, anything unprovable REFUSES to a human -- all-or-refuse, proven before any entry is touched); absent -> ROLL BACK (nothing was ever renamed; this module's staged temps and backups are swept, each removal reported). There is no re-validate-and-proceed path because there is nowhere for one to live.

coordinator.sgl -- the F3a manifest-refuse branch becomes D11's own sentence: recovery runs automatically inside the lock BEFORE accepting another mutation, and the recovery report is handed to the transaction thunk. Results and refusals carry recovery-actions so a self-heal is never silent; an unparseable manifest still refuses every mutation to a human, which is the F3a behaviour an unprovable state keeps.

transaction.sgl -- folio-edit-body! becomes the real body-replacement door (Q1 ruling: amendment 1 names body edits as a category; the F3a refusal message promised them to F3b). Exact revision match only, NEVER rebased; the mismatch path keeps its own 'structural-revision-mismatch reason, pinned distinguishable from every scalar refusal. F3b scope is PROSE bodies: a body declaring node ids refuses ('body-declares-ids -- child surgery belongs to the structural ops), a replacement that would declare ids refuses at the postcheck, and the variable node-delta swaps instruments: the postcheck asserts the count OUTSIDE the target's subtree is unchanged, which catches a brace escaping the body (smoked: an injected task never landed). Body extent is parser-owned (first to last child span, both spellings). Adds the narrow % surface the structural module builds on, aliased so every reviewed F3a spelling stays put.

Probe arms a7/a-manifest updated to the ruled semantics (id-declaring body refuses; unparseable manifest refuses to a human); battery 22/22 green; recovery smoke: complete branch reports its rename, debris branch reports each sweep.

Changed
 src/folio/doc/coordinator.sgl |  33 ++++++++++++--------
 src/folio/doc/manifest.sgl    | 260 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/folio/doc/transaction.sgl | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
 test/f3a-txn-probe.sgl        |  20 +++++++++---
 4 files changed, 539 insertions(+), 43 deletions(-)
Diff
src/folio/doc/coordinator.sglmodified
@@ -53,7 +53,8 @@
53
(sigil time)
54
(folio doc lock)
55
(folio doc service)
−56
(folio doc bounded))
+56
(folio doc bounded)
+57
(folio doc manifest))
58
(export make-coordinator coordinator?
59
coordinator-root coordinator-service coordinator-probed?
60
transaction-manifest-name
@@ -165,11 +166,23 @@
166
#t))
167
168
;;; Run `thunk` as a transaction body: in-process mutex, cross-process
−168
;;; lock, manifest check, always-release. The thunk runs WHILE THE LOCK
−169
;;; IS HELD; everything the transaction layer does between reread and
−170
;;; publish happens inside it. Serialization discipline lives here so
−171
;;; that no transaction path can acquire the lock without also taking the
−172
;;; in-process mutex and the manifest check -- there is exactly one door.
+169
;;; lock, CRASH RECOVERY, always-release. The thunk runs WHILE THE LOCK
+170
;;; IS HELD and receives the recovery report -- a list of action strings,
+171
;;; '() when there was nothing to recover -- so doors carry it into their
+172
;;; results: a silent self-heal is indistinguishable from nothing having
+173
;;; been wrong, which is the D6-journal defect one layer up.
+174
;;;
+175
;;; F3a REFUSED on finding a manifest; F3b replaces that branch with
+176
;;; D11's own sentence: "the next writer checks for an incomplete
+177
;;; transaction manifest and either completes the already-validated
+178
;;; rename set or rolls it back BEFORE ACCEPTING ANOTHER MUTATION."
+179
;;; `recover-root!` implements exactly those two branches (manifest
+180
;;; presence = validated = complete; absence = sweep pre-commit debris)
+181
;;; and RAISES to a human when a state cannot be proven safe -- an
+182
;;; unprovable state still gets the refusing behaviour F3a shipped.
+183
;;; Serialization discipline lives here so that no transaction path can
+184
;;; acquire the lock without also passing recovery -- there is exactly
+185
;;; one door.
186
(define (coordinator-transact c thunk)
187
(unless (coordinator-probed? c)
188
(error "coordinator-transact: this coordinator's exclusion probe has not passed"))
@@ -187,12 +200,6 @@
200
(with-root-lock (coordinator-record-root c)
201
(coordinator-record-lock-wait-seconds c)
202
(lambda ()
−190
(let ((manifest (path-join (coordinator-record-root c)
−191
transaction-manifest-name)))
−192
(when (file-exists? manifest)
−193
(error (format
−194
"coordinator: found an incomplete transaction manifest at ~a. An earlier cross-file transaction did not finish; recovery is F3b's -- refusing every mutation on this root until it is resolved."
−195
manifest)))
−196
(thunk))))))
+203
(thunk (recover-root! (coordinator-record-root c)))))))
204
(set-car! busy #f)
205
result))))))
src/folio/doc/manifest.sgladded
@@ -0,0 +1,260 @@
+1
;;; (folio doc manifest) -- the cross-file transaction manifest and its
+2
;;; crash recovery (F3b).
+3
;;;
+4
;;; D11: "Cross-file transactions stage and validate the complete revision set
+5
;;; while holding the same root lock, then retain a bounded filesystem
+6
;;; crash-recovery manifest until every rename completes ... After acquiring
+7
;;; the released lock, the next writer checks for an incomplete transaction
+8
;;; manifest and either completes the already-validated rename set or rolls it
+9
;;; back before accepting another mutation."
+10
;;;
+11
;;; THE DISCRIMINATOR IS PRESENCE, BY CONSTRUCTION. The manifest is written
+12
;;; ATOMICALLY (temp + rename) and ONLY AFTER every staged file has passed its
+13
;;; full validation; target renames begin only after the manifest exists on
+14
;;; disk. Therefore:
+15
;;;
+16
;;; manifest present => the rename set was ALREADY VALIDATED, and recovery
+17
;;; COMPLETES it (or refuses to a human where
+18
;;; completion cannot be proven safe);
+19
;;; manifest absent => no target file was ever renamed, and recovery
+20
;;; ROLLS BACK by sweeping this module's staged temps
+21
;;; and backups.
+22
;;;
+23
;;; There is deliberately NO re-validate-and-proceed path: nothing in this
+24
;;; module re-runs validation, because a manifest that exists IS the proof
+25
;;; validation happened. A recovery path that re-validated and proceeded
+26
;;; would be a different and much worse thing than one that finishes a
+27
;;; decided operation (leader ruling, 2026-08-25, on D11's "already-validated").
+28
;;;
+29
;;; WHAT THE MANIFEST IS NOT. Operational recovery metadata only -- never
+30
;;; authoritative knowledge state. Nothing reads a manifest to learn what a
+31
;;; note says; it is read once, inside the lock, to finish or undo an
+32
;;; interrupted publish, and then removed.
+33
;;;
+34
;;; FORMAT. One line `F3B_MANIFEST v1 <txn-id> <op-label>` then one line per
+35
;;; file: tab-separated `FILE <target> <pre-rev> <temp> <temp-hash> <post-rev>
+36
;;; <backup> <backup-hash>`, then the terminator line `COMPLETE`. A manifest
+37
;;; 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 have
+40
;;; begun, and an unprovable state is reported, not repaired. Paths are
+41
;;; 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 crashed
+45
;;; writer leaves whole files, never torn ones), not power-loss durability of
+46
;;; 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-name
+57
staging-prefix backup-prefix
+58
mentry mentry? mentry-target mentry-pre-rev mentry-temp
+59
mentry-temp-hash mentry-post-rev mentry-backup mentry-backup-hash
+60
write-transaction-manifest! clear-transaction-manifest!
+61
read-transaction-manifest
+62
recover-root!)
+63
(begin
+64
+65
;; 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-")
+69
+70
(define-struct mentry-record
+71
(target) (pre-rev) (temp) (temp-hash) (post-rev) (backup) (backup-hash))
+72
+73
(define (mentry target pre-rev temp temp-hash post-rev backup backup-hash)
+74
(mentry-record target: target pre-rev: pre-rev temp: temp
+75
temp-hash: temp-hash post-rev: post-rev
+76
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))
+85
+86
;;; Write the manifest ATOMICALLY: the temp is fully written first, the
+87
;;; rename is the commit point. Callers must call this only after every
+88
;;; entry's staged file passed validation -- presence means validated, and
+89
;;; this module cannot check that for them, so the ORDER OF CALLS in the
+90
;;; 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-append
+95
(format "F3B_MANIFEST v1 ~a ~a~%" txn-id op-label)
+96
(append
+97
(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))
+108
+109
(define (clear-transaction-manifest! root)
+110
(let ((path (path-join root manifest-file-name)))
+111
(when (file-exists? path) (delete-file path))))
+112
+113
;;; Parse the manifest. Returns (list txn-id op-label entries) or the
+114
;;; symbol 'corrupt. Absent is the CALLER's branch (file-exists?), so a
+115
;;; 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
'corrupt
+121
(let ((lines (string-split text "\n")))
+122
(if (or (null? lines)
+123
(not (string-starts-with? (car lines) "F3B_MANIFEST v1 ")))
+124
'corrupt
+125
(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
(cond
+130
((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)))))))))
+148
+149
(define (hash-of-file path)
+150
(guard (e (#t #f)) (revision-of-file path)))
+151
+152
;;; RECOVERY. Runs inside the root lock (the coordinator calls it before
+153
;;; accepting any mutation). Returns a LIST OF ACTION STRINGS -- '() when
+154
;;; there was nothing to recover -- which the coordinator threads into the
+155
;;; transaction's result so a self-heal is never silent. RAISES with a
+156
;;; human-directed message when the state cannot be proven safe to finish
+157
;;; or undo; it never guesses.
+158
;;;
+159
;;; Two branches, per the discriminator:
+160
;;;
+161
;;; COMPLETE (manifest present and parseable): every entry is finished
+162
;;; individually --
+163
;;; target already at post-rev -> already published, note it
+164
;;; target at pre-rev, temp hash matches -> rename the temp in
+165
;;; anything else -> REFUSE to a human: either
+166
;;; the temp is gone (cannot complete) or the target is at a THIRD
+167
;;; revision (an external edit raced the crash window; finishing would
+168
;;; clobber it, rolling back would clobber it, only a human knows).
+169
;;; Backups and the manifest are removed only after every entry finished.
+170
;;;
+171
;;; ROLLBACK (no manifest): nothing was ever renamed, so recovery is
+172
;;; sweeping THIS MODULE'S debris -- stale staging temps and backups from
+173
;;; a transaction that died before its commit point. Targets are not
+174
;;; touched (there is nothing to restore; they were never written).
+175
(define (recover-root! root)
+176
(let ((mpath (path-join root manifest-file-name)))
+177
(if (file-exists? mpath)
+178
(recover-complete! root)
+179
(sweep-debris! root))))
+180
+181
(define (recover-complete! root)
+182
(let ((parsed (read-transaction-manifest root)))
+183
(when (eq? parsed 'corrupt)
+184
(error (format
+185
"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."
+186
root manifest-file-name)))
+187
(let ((txn-id (list-ref parsed 0))
+188
(op-label (list-ref parsed 1))
+189
(entries (list-ref parsed 2)))
+190
;; First pass: PROVE every entry is finishable before touching
+191
;; anything -- completion is all-or-refuse, not best-effort.
+192
(for-each
+193
(lambda (e)
+194
(let* ((target (path-join root (mentry-target e)))
+195
(cur (hash-of-file target)))
+196
(cond
+197
((equal? cur (mentry-post-rev e)) #t)
+198
((and (equal? cur (mentry-pre-rev e))
+199
(equal? (hash-of-file (path-join root (mentry-temp e)))
+200
(mentry-temp-hash e)))
+201
#t)
+202
(else
+203
(error (format
+204
"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."
+205
(mentry-target e) (or cur "unreadable")
+206
(mentry-pre-rev e) (mentry-post-rev e)
+207
(if (equal? (hash-of-file (path-join root (mentry-temp e)))
+208
(mentry-temp-hash e))
+209
"intact" "missing or altered")
+210
txn-id op-label))))))
+211
entries)
+212
;; Second pass: finish.
+213
(let ((actions
+214
(map (lambda (e)
+215
(let* ((target (path-join root (mentry-target e)))
+216
(cur (hash-of-file target)))
+217
(if (equal? cur (mentry-post-rev e))
+218
(format "recovery: ~a was already published by the interrupted transaction ~a" (mentry-target e) txn-id)
+219
(begin
+220
(rename-file (path-join root (mentry-temp e)) target)
+221
(format "recovery: COMPLETED the validated rename of ~a for interrupted transaction ~a (~a)" (mentry-target e) txn-id op-label)))))
+222
entries)))
+223
;; Only now is the metadata safe to drop.
+224
(for-each (lambda (e)
+225
(let ((b (path-join root (mentry-backup e))))
+226
(guard (x (#t #f)) (when (file-exists? b) (delete-file b)))))
+227
entries)
+228
(clear-transaction-manifest! root)
+229
actions))))
+230
+231
;;; 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.
+235
(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)))))))
src/folio/doc/transaction.sglmodified
@@ -98,19 +98,33 @@
98
txn-result-changed-span-start txn-result-changed-span-end
99
txn-result-rebased? txn-result-rebase-base-revision
100
txn-result-update-id txn-result-diff
+101
txn-result-recovery-actions
102
;; refusals
103
txn-refusal? txn-refusal-reason txn-refusal-message
104
txn-refusal-current-revision txn-refusal-target-context
−104
txn-refusal-diff txn-refusal-retryable?)
+105
txn-refusal-diff txn-refusal-retryable?
+106
txn-refusal-recovery-actions
+107
;; --- the % surface (folio doc structural) builds on ------------------
+108
;; Additive, deliberately narrow: the reviewed F3a machinery is reused,
+109
;; not refactored. Nothing here is for general callers.
+110
%txn-refuse %txn-stamp-recovery %txn-bounded-descriptor %txn-find-node
+111
%txn-node-context %txn-splice %txn-scalar->byte-offset %txn-publish!
+112
%txn-verify-staged %txn-make-line-diff %txn-require-contained-path
+113
%txn-require-safe-journal-text %txn-escape-string-literal
+114
%txn-body-insertion-point %txn-subtree-count %txn-subtree-declared-ids
+115
%txn-body-extent %txn-bytes-equal?)
116
(begin
117
118
;; ------------------------------------------------------------------
119
;; Result and refusal records
120
;; ------------------------------------------------------------------
+121
;; `recovery` (F3b): the crash-recovery actions the coordinator performed
+122
;; before this transaction ran -- '() when there was nothing to recover.
+123
;; Carried on results AND refusals so a self-heal is never silent.
124
(define-struct txn-result-record
125
(path) (node-id) (field) (old-revision) (new-revision)
126
(old-value) (new-value) (span-start) (span-end)
−113
(rebased) (rebase-base-revision) (update-id) (diff))
+127
(rebased) (rebase-base-revision) (update-id) (diff) (recovery))
128
129
(define (txn-result? v) (txn-result-record? v))
130
(define (txn-result-path r) (txn-result-record-path r))
@@ -126,13 +140,15 @@
140
(define (txn-result-rebase-base-revision r) (txn-result-record-rebase-base-revision r))
141
(define (txn-result-update-id r) (txn-result-record-update-id r))
142
(define (txn-result-diff r) (txn-result-record-diff r))
+143
(define (txn-result-recovery-actions r) (txn-result-record-recovery r))
144
145
;; A refusal is a RETURN VALUE, not a raise: it is the transaction doing
146
;; its job, and the caller must be able to read the fresh context without
147
;; exception plumbing. Raises are reserved for caller errors (malformed
148
;; arguments) and environment failures (lock, manifest, probe).
149
(define-struct txn-refusal-record
−135
(reason) (message) (current-revision) (target-context) (diff) (retryable))
+150
(reason) (message) (current-revision) (target-context) (diff) (retryable)
+151
(recovery))
152
153
(define (txn-refusal? v) (txn-refusal-record? v))
154
(define (txn-refusal-reason r) (txn-refusal-record-reason r))
@@ -141,13 +157,46 @@
157
(define (txn-refusal-target-context r) (txn-refusal-record-target-context r))
158
(define (txn-refusal-diff r) (txn-refusal-record-diff r))
159
(define (txn-refusal-retryable? r) (txn-refusal-record-retryable r))
+160
(define (txn-refusal-recovery-actions r) (txn-refusal-record-recovery r))
161
162
(define (refuse reason message (keys: (current-revision #f) (target-context #f)
163
(diff #f) (retryable #f)))
164
(txn-refusal-record reason: reason message: message
165
current-revision: current-revision
166
target-context: target-context
−150
diff: diff retryable: retryable))
+167
diff: diff retryable: retryable recovery: '()))
+168
+169
;; Doors stamp the coordinator's recovery report onto whatever the
+170
;; transaction body returned. Records are immutable, so the stamp is a
+171
;; reconstruction; '() stays '() and costs nothing to read.
+172
(define (stamp-recovery r actions)
+173
(cond
+174
((null? actions) r)
+175
((txn-result-record? r)
+176
(txn-result-record
+177
path: (txn-result-record-path r) node-id: (txn-result-record-node-id r)
+178
field: (txn-result-record-field r)
+179
old-revision: (txn-result-record-old-revision r)
+180
new-revision: (txn-result-record-new-revision r)
+181
old-value: (txn-result-record-old-value r)
+182
new-value: (txn-result-record-new-value r)
+183
span-start: (txn-result-record-span-start r)
+184
span-end: (txn-result-record-span-end r)
+185
rebased: (txn-result-record-rebased r)
+186
rebase-base-revision: (txn-result-record-rebase-base-revision r)
+187
update-id: (txn-result-record-update-id r)
+188
diff: (txn-result-record-diff r)
+189
recovery: actions))
+190
((txn-refusal-record? r)
+191
(txn-refusal-record
+192
reason: (txn-refusal-record-reason r)
+193
message: (txn-refusal-record-message r)
+194
current-revision: (txn-refusal-record-current-revision r)
+195
target-context: (txn-refusal-record-target-context r)
+196
diff: (txn-refusal-record-diff r)
+197
retryable: (txn-refusal-record-retryable r)
+198
recovery: actions))
+199
(else r)))
200
201
;; ------------------------------------------------------------------
202
;; The taxonomy, as data
@@ -472,7 +521,13 @@
521
;; update-child checks above only look where the request
522
;; pointed, and an injection lands precisely where nobody
523
;; points (the pre-review hunt's finding, closed as a class).
−475
((not (= (descriptor-node-count staged-d) (+ pre-node-count node-delta)))
+524
;; node-delta #f = the request's delta is inherently variable
+525
;; (body replacement); the target-check then carries the
+526
;; outside-the-subtree count invariant instead, so the class
+527
;; the exact delta closes (injection where nobody points)
+528
;; stays closed by a different instrument, never waived.
+529
((and node-delta
+530
(not (= (descriptor-node-count staged-d) (+ pre-node-count node-delta))))
531
(refuse refusal-reason
532
(format "post-edit check: the staged document holds ~a nodes where exactly ~a were expected (~a before the edit, delta ~a); something was injected or lost -- refusing to publish"
533
(descriptor-node-count staged-d)
@@ -555,9 +610,10 @@
610
(error "transaction: expected-revision must be \"sha256:\" + 64 lowercase hex digits over the raw file bytes"))
611
(let ((path (require-contained-path coord path)))
612
(coordinator-transact coord
−558
(lambda ()
+613
(lambda (recovery-actions)
614
(unless (file-exists? path)
615
(error (format "transaction: no such file: ~a" path)))
+616
(stamp-recovery
617
(let* ((bytes (read-file-bytes path))
618
(rev (revision-of-bytes bytes))
619
(source (utf8->string bytes)))
@@ -593,7 +649,8 @@
649
(if (txn-refusal? pre)
650
pre
651
(attempt coord path bytes source rev stage
−596
#t expected-revision field node-id))))))))))))
+652
#t expected-revision field node-id))))))))
+653
recovery-actions)))))
654
655
(define (attempt coord path bytes source rev stage rebased? base-revision field node-id)
656
(let ((d (bounded-descriptor coord bytes source path rev "the note")))
@@ -637,7 +694,8 @@
694
rebased: rebased?
695
rebase-base-revision: (and rebased? base-revision)
696
update-id: update-id
−640
diff: (make-line-diff source staged-source))))))))))
+697
diff: (make-line-diff source staged-source)
+698
recovery: '())))))))))
699
700
;; ------------------------------------------------------------------
701
;; Door 1: scalar field update (everything but status)
@@ -1056,35 +1114,172 @@
1114
(else #f)))))))
1115
1116
;; ------------------------------------------------------------------
−1059
;; Door 3: body/structural requests -- classify and refuse (amendment 1)
+1117
;; Door 3: BODY REPLACEMENT (F3b) -- exact revision match only, NEVER
+1118
;; rebased (amendment 1 names body edits as the category whose mismatch
+1119
;; behaviour is refusal, and body prose is precisely where a stale rebase
+1120
;; silently destroys meaning; leader ruling 2026-08-25).
+1121
;;
+1122
;; F3b's scope is PROSE BODIES: a body that currently declares node ids
+1123
;; (journal entries, nested work items) refuses, and a replacement that
+1124
;; would introduce id-declaring nodes refuses at the postcheck --
+1125
;; child-node surgery belongs to the structural ops, where each id change
+1126
;; is individually checked against its referrers. The body EXTENT is
+1127
;; parser-owned: the span from the first to the last of the node's
+1128
;; children (identical for braced and named-block spellings); an empty
+1129
;; body uses the journal insertion point with zero length.
+1130
;;
+1131
;; The exact node-count delta is inherently variable here, so the
+1132
;; injection-class instrument swaps: the postcheck asserts the node count
+1133
;; OUTSIDE the target's subtree is unchanged -- a `}` in the new text
+1134
;; that escaped the body would push former children out as siblings and
+1135
;; move that number.
1136
;; ------------------------------------------------------------------
+1137
(define (subtree-count n)
+1138
(let loop ((pending (list n)) (total 0))
+1139
(if (null? pending)
+1140
total
+1141
(loop (append (inspected-node-children (car pending)) (cdr pending))
+1142
(+ total 1)))))
+1143
(define (subtree-declared-ids n)
+1144
;; Ids declared STRICTLY BELOW n (n's own id is the address, not body).
+1145
(let loop ((pending (inspected-node-children n)) (ids '()))
+1146
(cond ((null? pending) (reverse ids))
+1147
(else
+1148
(let ((id (inspected-node-id (car pending))))
+1149
(loop (append (inspected-node-children (car pending)) (cdr pending))
+1150
(if id (cons id ids) ids)))))))
+1151
(define (body-extent source node)
+1152
;; (start . end) in SCALAR offsets, or #f when the node has no body
+1153
;; delimiter at all.
+1154
(let ((children (inspected-node-children node)))
+1155
(if (pair? children)
+1156
(let ((first-sp (inspected-node-span (car children)))
+1157
(last-sp (inspected-node-span (list-ref children (- (length children) 1)))))
+1158
(cons (span-scalar-start first-sp) (span-scalar-end last-sp)))
+1159
(let ((at (update-insertion-point source node)))
+1160
(and at (cons at at))))))
+1161
1162
(define (folio-edit-body! coord path node-id new-text expected-revision)
1163
(unless (coordinator? coord) (error "folio-edit-body!: coord must be a coordinator"))
+1164
(unless (string? new-text) (error "folio-edit-body!: new-text must be a string"))
+1165
(let ((stage
+1166
(lambda (d source rev)
+1167
(stage-body-replace d source rev node-id new-text))))
+1168
(run-body-transaction coord path expected-revision stage node-id)))
+1169
+1170
;; Bodies get their own runner because their mismatch refusal has its own
+1171
;; ruled reason ('structural-revision-mismatch, amendment 1) -- reusing
+1172
;; run-transaction would report the scalar 'revision-mismatch and the two
+1173
;; classes must stay distinguishable (F3a gate arm a7 pins that).
+1174
(define (run-body-transaction coord path expected-revision stage node-id)
1175
(unless (valid-revision? expected-revision)
1176
(error "folio-edit-body!: expected-revision must be a valid revision"))
1177
(let ((path (require-contained-path coord path)))
1178
(coordinator-transact coord
−1067
(lambda ()
+1179
(lambda (recovery-actions)
1180
(unless (file-exists? path)
1181
(error (format "folio-edit-body!: no such file: ~a" path)))
+1182
(stamp-recovery
1183
(let* ((bytes (read-file-bytes path))
1184
(rev (revision-of-bytes bytes))
1185
(source (utf8->string bytes)))
−1073
(if (equal? rev expected-revision)
−1074
(refuse 'structural-unimplemented
−1075
"body and structural edits are F3b; this coordinator refuses them even on a clean revision match"
−1076
current-revision: rev)
−1077
(let* ((d (bounded-descriptor coord bytes source path rev "the note"))
−1078
(context (and (descriptor? d)
−1079
(let ((n (descriptor-node-by-id d node-id)))
−1080
(and n (node-context source n))))))
−1081
(refuse 'structural-revision-mismatch
−1082
(format "expected revision ~a but the file is at ~a. Structural and body edits ALWAYS refuse on revision mismatch (D11 amendment 1) -- a body edit's meaning depends on context the rebase check does not cover. Reread and re-decide."
−1083
expected-revision rev)
−1084
current-revision: rev
−1085
target-context: context
−1086
diff: (format "-expected-revision ~a~%+current-revision ~a~%"
−1087
expected-revision rev)))))))))
+1186
(cond
+1187
((not (bytes-equal? (string->utf8 source) bytes))
+1188
(refuse 'encoding-not-roundtrip
+1189
"this file's bytes do not survive UTF-8 decode/encode; span-addressed editing would corrupt it, refusing"
+1190
current-revision: rev))
+1191
((equal? rev expected-revision)
+1192
(attempt coord path bytes source rev stage #f expected-revision 'body node-id))
+1193
(else
+1194
(let* ((d (bounded-descriptor coord bytes source path rev "the note"))
+1195
(context (and (descriptor? d)
+1196
(let ((n (descriptor-node-by-id d node-id)))
+1197
(and n (node-context source n))))))
+1198
(refuse 'structural-revision-mismatch
+1199
(format "expected revision ~a but the file is at ~a. Structural and body edits ALWAYS refuse on revision mismatch (D11 amendment 1) -- a body edit's meaning depends on context the rebase check does not cover. Reread and re-decide."
+1200
expected-revision rev)
+1201
current-revision: rev
+1202
target-context: context
+1203
diff: (format "-expected-revision ~a~%+current-revision ~a~%"
+1204
expected-revision rev))))))
+1205
recovery-actions)))))
+1206
+1207
(define (stage-body-replace d source rev node-id new-text)
+1208
(let ((node (find-node d node-id rev)))
+1209
(if (txn-refusal? node)
+1210
node
+1211
(let ((declared (subtree-declared-ids node)))
+1212
(cond
+1213
((pair? declared)
+1214
(refuse 'body-declares-ids
+1215
(format "the body of ~s declares node id(s) ~a; replacing it wholesale would delete them blind to their referrers. Use the structural operations for child-node surgery -- body replacement is for PROSE bodies (F3b scope ruling)."
+1216
node-id declared)
+1217
current-revision: rev
+1218
target-context: (node-context source node)))
+1219
(else
+1220
(let ((extent (body-extent source node)))
+1221
(if (not extent)
+1222
(refuse 'update-target-has-no-body
+1223
(format "node ~s has neither a braced body nor an @end block to replace into" node-id)
+1224
current-revision: rev)
+1225
(let* ((start (car extent))
+1226
(end (cdr extent))
+1227
(old-body (substring source start end))
+1228
(staged (splice source start end new-text))
+1229
(byte-start (scalar->byte-offset source start))
+1230
(byte-end (scalar->byte-offset source end))
+1231
(kind (inspected-node-name node))
+1232
(pre-total (descriptor-node-count d))
+1233
(pre-subtree (subtree-count node))
+1234
(pre-fields (map (lambda (f) (cons (field-name f) (field-value f)))
+1235
(inspected-node-fields node))))
+1236
(list staged
+1237
byte-start
+1238
(- byte-end byte-start)
+1239
(bytevector-length (string->utf8 new-text))
+1240
old-body
+1241
new-text
+1242
#f
+1243
#f ;; node delta: variable; the check below
+1244
;; carries the outside-subtree invariant
+1245
(lambda (staged-d)
+1246
(body-replace-check staged-d node-id kind pre-fields
+1247
pre-total pre-subtree rev))))))))))))
+1248
+1249
(define (body-replace-check staged-d node-id kind pre-fields pre-total pre-subtree rev)
+1250
(let ((n2 (descriptor-node-by-id staged-d node-id)))
+1251
(cond
+1252
((not n2)
+1253
(refuse 'postcheck-failed
+1254
(format "the target node ~s vanished from the staged document" node-id)
+1255
current-revision: rev))
+1256
((not (eq? (inspected-node-name n2) kind))
+1257
(refuse 'postcheck-failed
+1258
(format "the staged node ~s changed kind (~a -> ~a)" node-id kind
+1259
(inspected-node-name n2))
+1260
current-revision: rev))
+1261
((not (equal? pre-fields
+1262
(map (lambda (f) (cons (field-name f) (field-value f)))
+1263
(inspected-node-fields n2))))
+1264
(refuse 'postcheck-failed
+1265
(format "the staged node ~s's argument fields moved; a body replacement must not touch them" node-id)
+1266
current-revision: rev))
+1267
((pair? (subtree-declared-ids n2))
+1268
(refuse 'body-would-declare-ids
+1269
(format "the replacement body declares node id(s) ~a; body replacement is for PROSE -- use the structural operations to add id-bearing nodes"
+1270
(subtree-declared-ids n2))
+1271
current-revision: rev))
+1272
;; The escape detector: nodes OUTSIDE the target's subtree must be
+1273
;; exactly as numerous as before. A brace escaping the body pushes
+1274
;; former body content out as siblings and moves this number.
+1275
((not (= (- (descriptor-node-count staged-d) (subtree-count n2))
+1276
(- pre-total pre-subtree)))
+1277
(refuse 'postcheck-failed
+1278
(format "the replacement body escaped its extent: ~a nodes now live outside ~s's subtree where ~a did before"
+1279
(- (descriptor-node-count staged-d) (subtree-count n2))
+1280
node-id (- pre-total pre-subtree))
+1281
current-revision: rev))
+1282
(else #f))))
1283
1284
;; ------------------------------------------------------------------
1285
;; Door 2b: append-only @update without a status change
@@ -1164,6 +1359,30 @@
1359
current-revision: rev))
1360
(else #f)))))))))))))
1361
+1362
;; ------------------------------------------------------------------
+1363
;; The % surface: aliases binding the module-private machinery for
+1364
;; (folio doc structural). Aliased rather than renamed so every F3a
+1365
;; call site and comment keeps its reviewed spelling.
+1366
;; ------------------------------------------------------------------
+1367
(define %txn-refuse refuse)
+1368
(define %txn-stamp-recovery stamp-recovery)
+1369
(define %txn-bounded-descriptor bounded-descriptor)
+1370
(define %txn-find-node find-node)
+1371
(define %txn-node-context node-context)
+1372
(define %txn-splice splice)
+1373
(define %txn-scalar->byte-offset scalar->byte-offset)
+1374
(define %txn-publish! publish!)
+1375
(define %txn-verify-staged verify-staged)
+1376
(define %txn-make-line-diff make-line-diff)
+1377
(define %txn-require-contained-path require-contained-path)
+1378
(define %txn-require-safe-journal-text require-safe-journal-text)
+1379
(define %txn-escape-string-literal escape-string-literal)
+1380
(define %txn-body-insertion-point update-insertion-point)
+1381
(define %txn-subtree-count subtree-count)
+1382
(define %txn-subtree-declared-ids subtree-declared-ids)
+1383
(define %txn-body-extent body-extent)
+1384
(define %txn-bytes-equal? bytes-equal?)
+1385
1386
;; Count a node's direct `update` children. Shared by the two journal
1387
;; target-checks: "an update child EXISTS" is satisfied by any prior
1388
;; journal entry, so the check that OUR entry landed is a COUNT increase
test/f3a-txn-probe.sglmodified
@@ -326,9 +326,13 @@
326
(assert-refusal "scalar-but-not-allowlisted on mismatch" title 'revision-mismatch)
327
(assert-true "the two refusals are distinguishable"
328
(not (eq? (txn-refusal-reason body) (txn-refusal-reason title))))))
−329
(assert-refusal "body edit on a CLEAN match still refuses (F3b)"
+329
;; F3b: body edits WORK on a clean match now (Q1 ruling) -- but t-alpha
+330
;; carries journal children by this point in the battery, and an
+331
;; id-declaring body refuses wholesale replacement: deleting ids blind to
+332
;; their referrers is the structural ops' job to do safely.
+333
(assert-refusal "body edit on an id-declaring body refuses (F3b scope)"
334
(folio-edit-body! coord note "t-alpha" "replacement body" (rev-of note))
−331
'structural-unimplemented)))
+335
'body-declares-ids)))
336
337
;; ---------------------------------------------------------------------------
338
;; a-aba -- ABA on an allowlisted field, accepted explicitly: the file went
@@ -395,14 +399,20 @@
399
;; ---------------------------------------------------------------------------
400
(arm "a-manifest"
401
(lambda ()
+402
;; F3b: a manifest that PARSES triggers automatic recovery (the F3b probe
+403
;; covers those branches); this arm keeps the UNPROVABLE case pinned -- a
+404
;; manifest that does not parse must refuse every mutation to a HUMAN,
+405
;; never guess, and its removal stands the coordinator back up.
406
(let ((manifest (path-join root transaction-manifest-name)))
407
(write-file-string manifest "left by a crashed cross-file transaction")
408
(let ((raised (guard (e (#t (if (exception? e) (exception-message e) "raised")))
409
(folio-update-field! coord note "t-alpha" 'priority 'low (rev-of note))
410
#f)))
−403
(assert-true "transaction refused while a manifest exists" (string? raised))
−404
(assert-true "the refusal names the manifest and F3b"
−405
(and (string-contains? raised "manifest") (string-contains? raised "F3b"))))
+411
(assert-true "transaction refused while an unparseable manifest exists"
+412
(string? raised))
+413
(assert-true "the refusal names the manifest and demands a human"
+414
(and (string-contains? raised "manifest")
+415
(string-contains? raised "human"))))
416
(delete-file manifest)
417
(assert-result "removal stands the coordinator back up"
418
(folio-update-field! coord note "t-alpha" 'priority 'low (rev-of note)