F3b: crash recovery, the manifest, and body replacement
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.
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(-)src/folio/doc/coordinator.sglmodified
(sigil time) (folio doc lock) (folio doc service) (folio doc bounded)) (folio doc bounded) (folio doc manifest)) (export make-coordinator coordinator? coordinator-root coordinator-service coordinator-probed? transaction-manifest-name #t)) ;;; Run `thunk` as a transaction body: in-process mutex, cross-process ;;; lock, manifest check, always-release. The thunk runs WHILE THE LOCK ;;; IS HELD; everything the transaction layer does between reread and ;;; publish happens inside it. Serialization discipline lives here so ;;; that no transaction path can acquire the lock without also taking the ;;; in-process mutex and the manifest check -- there is exactly one door. ;;; lock, CRASH RECOVERY, always-release. The thunk runs WHILE THE LOCK ;;; IS HELD and receives the recovery report -- a list of action strings, ;;; '() when there was nothing to recover -- so doors carry it into their ;;; results: a silent self-heal is indistinguishable from nothing having ;;; been wrong, which is the D6-journal defect one layer up. ;;; ;;; F3a REFUSED on finding a manifest; F3b replaces that branch with ;;; D11's own sentence: "the next writer checks for an incomplete ;;; transaction manifest and either completes the already-validated ;;; rename set or rolls it back BEFORE ACCEPTING ANOTHER MUTATION." ;;; `recover-root!` implements exactly those two branches (manifest ;;; presence = validated = complete; absence = sweep pre-commit debris) ;;; and RAISES to a human when a state cannot be proven safe -- an ;;; unprovable state still gets the refusing behaviour F3a shipped. ;;; Serialization discipline lives here so that no transaction path can ;;; acquire the lock without also passing recovery -- there is exactly ;;; one door. (define (coordinator-transact c thunk) (unless (coordinator-probed? c) (error "coordinator-transact: this coordinator's exclusion probe has not passed")) (with-root-lock (coordinator-record-root c) (coordinator-record-lock-wait-seconds c) (lambda () (let ((manifest (path-join (coordinator-record-root c) transaction-manifest-name))) (when (file-exists? manifest) (error (format "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." manifest))) (thunk)))))) (thunk (recover-root! (coordinator-record-root c))))))) (set-car! busy #f) result))))))src/folio/doc/manifest.sgladded
;;; (folio doc manifest) -- the cross-file transaction manifest and its;;; crash recovery (F3b).;;;;;; D11: "Cross-file transactions stage and validate the complete revision set;;; while holding the same root lock, then retain a bounded filesystem;;; crash-recovery manifest until every rename completes ... After acquiring;;; the released lock, the next writer checks for an incomplete transaction;;; manifest and either completes the already-validated rename set or rolls it;;; back before accepting another mutation.";;;;;; THE DISCRIMINATOR IS PRESENCE, BY CONSTRUCTION. The manifest is written;;; ATOMICALLY (temp + rename) and ONLY AFTER every staged file has passed its;;; full validation; target renames begin only after the manifest exists on;;; disk. Therefore:;;;;;; manifest present => the rename set was ALREADY VALIDATED, and recovery;;; COMPLETES it (or refuses to a human where;;; completion cannot be proven safe);;;; manifest absent => no target file was ever renamed, and recovery;;; ROLLS BACK by sweeping this module's staged temps;;; and backups.;;;;;; There is deliberately NO re-validate-and-proceed path: nothing in this;;; module re-runs validation, because a manifest that exists IS the proof;;; validation happened. A recovery path that re-validated and proceeded;;; would be a different and much worse thing than one that finishes a;;; decided operation (leader ruling, 2026-08-25, on D11's "already-validated").;;;;;; WHAT THE MANIFEST IS NOT. Operational recovery metadata only -- never;;; authoritative knowledge state. Nothing reads a manifest to learn what a;;; note says; it is read once, inside the lock, to finish or undo an;;; interrupted publish, and then removed.;;;;;; FORMAT. One line `F3B_MANIFEST v1 <txn-id> <op-label>` then one line per;;; file: tab-separated `FILE <target> <pre-rev> <temp> <temp-hash> <post-rev>;;; <backup> <backup-hash>`, then the terminator line `COMPLETE`. A manifest;;; whose terminator is missing or whose lines do not parse is CORRUPT --;;; unreachable while POSIX rename is atomic, kept as a defensive branch --;;; and recovery REFUSES TO A HUMAN rather than guessing: renames may have;;; begun, and an unprovable state is reported, not repaired. Paths are;;; recorded root-relative so a root moved wholesale still recovers.;;;;;; DURABILITY, STATED HONESTLY: no fsync primitive exists on this toolchain,;;; so the ordering guarantees here are about rename ATOMICITY (a crashed;;; writer leaves whole files, never torn ones), not power-loss durability of;;; the metadata itself.(define-library (folio doc manifest) (import (sigil core) (sigil struct) (sigil string) (sigil list) (sigil fs) (sigil path) (sigil io) (folio doc revision)) (export manifest-file-name staging-prefix backup-prefix mentry mentry? mentry-target mentry-pre-rev mentry-temp mentry-temp-hash mentry-post-rev mentry-backup mentry-backup-hash write-transaction-manifest! clear-transaction-manifest! read-transaction-manifest recover-root!) (begin ;; One spelling, shared with (folio doc coordinator)'s check and the gate. (define manifest-file-name ".folio-transaction-manifest") (define staging-prefix ".folio-staging-") (define backup-prefix ".folio-backup-") (define-struct mentry-record (target) (pre-rev) (temp) (temp-hash) (post-rev) (backup) (backup-hash)) (define (mentry target pre-rev temp temp-hash post-rev backup backup-hash) (mentry-record target: target pre-rev: pre-rev temp: temp temp-hash: temp-hash post-rev: post-rev backup: backup backup-hash: backup-hash)) (define (mentry? v) (mentry-record? v)) (define (mentry-target e) (mentry-record-target e)) (define (mentry-pre-rev e) (mentry-record-pre-rev e)) (define (mentry-temp e) (mentry-record-temp e)) (define (mentry-temp-hash e) (mentry-record-temp-hash e)) (define (mentry-post-rev e) (mentry-record-post-rev e)) (define (mentry-backup e) (mentry-record-backup e)) (define (mentry-backup-hash e) (mentry-record-backup-hash e)) ;;; Write the manifest ATOMICALLY: the temp is fully written first, the ;;; rename is the commit point. Callers must call this only after every ;;; entry's staged file passed validation -- presence means validated, and ;;; this module cannot check that for them, so the ORDER OF CALLS in the ;;; publisher is load-bearing and its gate arm plants exactly a reordering. (define (write-transaction-manifest! root txn-id op-label entries) (let* ((path (path-join root manifest-file-name)) (temp (string-append path ".tmp")) (body (apply string-append (format "F3B_MANIFEST v1 ~a ~a~%" txn-id op-label) (append (map (lambda (e) (format "FILE\t~a\t~a\t~a\t~a\t~a\t~a\t~a~%" (mentry-target e) (mentry-pre-rev e) (mentry-temp e) (mentry-temp-hash e) (mentry-post-rev e) (mentry-backup e) (mentry-backup-hash e))) entries) (list "COMPLETE\n"))))) (write-file-string temp body) (rename-file temp path) path)) (define (clear-transaction-manifest! root) (let ((path (path-join root manifest-file-name))) (when (file-exists? path) (delete-file path)))) ;;; Parse the manifest. Returns (list txn-id op-label entries) or the ;;; symbol 'corrupt. Absent is the CALLER's branch (file-exists?), so a ;;; read here is always of something that claims to be a manifest. (define (read-transaction-manifest root) (let* ((path (path-join root manifest-file-name)) (text (guard (e (#t #f)) (read-file-string path)))) (if (not (string? text)) 'corrupt (let ((lines (string-split text "\n"))) (if (or (null? lines) (not (string-starts-with? (car lines) "F3B_MANIFEST v1 "))) 'corrupt (let* ((head (string-split (car lines) " ")) (txn-id (if (>= (length head) 3) (list-ref head 2) #f)) (op-label (if (>= (length head) 4) (list-ref head 3) #f))) (let loop ((rest (cdr lines)) (entries '()) (complete #f)) (cond ((null? rest) (if complete (list txn-id op-label (reverse entries)) 'corrupt)) ((equal? (car rest) "COMPLETE") (loop (cdr rest) entries #t)) ((equal? (car rest) "") (loop (cdr rest) entries complete)) ((string-starts-with? (car rest) "FILE\t") (let ((f (string-split (car rest) "\t"))) (if (= (length f) 8) (loop (cdr rest) (cons (mentry (list-ref f 1) (list-ref f 2) (list-ref f 3) (list-ref f 4) (list-ref f 5) (list-ref f 6) (list-ref f 7)) entries) complete) 'corrupt))) (else 'corrupt))))))))) (define (hash-of-file path) (guard (e (#t #f)) (revision-of-file path))) ;;; RECOVERY. Runs inside the root lock (the coordinator calls it before ;;; accepting any mutation). Returns a LIST OF ACTION STRINGS -- '() when ;;; there was nothing to recover -- which the coordinator threads into the ;;; transaction's result so a self-heal is never silent. RAISES with a ;;; human-directed message when the state cannot be proven safe to finish ;;; or undo; it never guesses. ;;; ;;; Two branches, per the discriminator: ;;; ;;; COMPLETE (manifest present and parseable): every entry is finished ;;; individually -- ;;; target already at post-rev -> already published, note it ;;; target at pre-rev, temp hash matches -> rename the temp in ;;; anything else -> REFUSE to a human: either ;;; the temp is gone (cannot complete) or the target is at a THIRD ;;; revision (an external edit raced the crash window; finishing would ;;; clobber it, rolling back would clobber it, only a human knows). ;;; Backups and the manifest are removed only after every entry finished. ;;; ;;; ROLLBACK (no manifest): nothing was ever renamed, so recovery is ;;; sweeping THIS MODULE'S debris -- stale staging temps and backups from ;;; a transaction that died before its commit point. Targets are not ;;; touched (there is nothing to restore; they were never written). (define (recover-root! root) (let ((mpath (path-join root manifest-file-name))) (if (file-exists? mpath) (recover-complete! root) (sweep-debris! root)))) (define (recover-complete! root) (let ((parsed (read-transaction-manifest root))) (when (eq? parsed 'corrupt) (error (format "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." root manifest-file-name))) (let ((txn-id (list-ref parsed 0)) (op-label (list-ref parsed 1)) (entries (list-ref parsed 2))) ;; First pass: PROVE every entry is finishable before touching ;; anything -- completion is all-or-refuse, not best-effort. (for-each (lambda (e) (let* ((target (path-join root (mentry-target e))) (cur (hash-of-file target))) (cond ((equal? cur (mentry-post-rev e)) #t) ((and (equal? cur (mentry-pre-rev e)) (equal? (hash-of-file (path-join root (mentry-temp e))) (mentry-temp-hash e))) #t) (else (error (format "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." (mentry-target e) (or cur "unreadable") (mentry-pre-rev e) (mentry-post-rev e) (if (equal? (hash-of-file (path-join root (mentry-temp e))) (mentry-temp-hash e)) "intact" "missing or altered") txn-id op-label)))))) entries) ;; Second pass: finish. (let ((actions (map (lambda (e) (let* ((target (path-join root (mentry-target e))) (cur (hash-of-file target))) (if (equal? cur (mentry-post-rev e)) (format "recovery: ~a was already published by the interrupted transaction ~a" (mentry-target e) txn-id) (begin (rename-file (path-join root (mentry-temp e)) target) (format "recovery: COMPLETED the validated rename of ~a for interrupted transaction ~a (~a)" (mentry-target e) txn-id op-label))))) entries))) ;; Only now is the metadata safe to drop. (for-each (lambda (e) (let ((b (path-join root (mentry-backup e)))) (guard (x (#t #f)) (when (file-exists? b) (delete-file b))))) entries) (clear-transaction-manifest! root) actions)))) ;;; Sweep staged temps and backups from a transaction that never reached ;;; its commit point. Only THIS MODULE'S prefixes, only at the root's top ;;; level and one directory deep (where notes live); nothing else is ;;; touched, and every removal is reported. (define (sweep-debris! root) (let ((actions '())) (for-each (lambda (dir) (for-each (lambda (name) (when (or (string-starts-with? name staging-prefix) (string-starts-with? name backup-prefix)) (let ((full (path-join dir name))) (guard (e (#t #f)) (delete-file full)) (set! actions (cons (format "recovery: ROLLED BACK (swept pre-commit debris ~a -- no manifest existed, so no target was ever renamed)" full) actions))))) (guard (e (#t '())) (directory-list dir)))) (cons root (filter (lambda (p) (directory-exists? p)) (map (lambda (n) (path-join root n)) (guard (e (#t '())) (directory-list root)))))) (reverse actions))) (define (filter pred xs) (let loop ((rest xs) (out '())) (cond ((null? rest) (reverse out)) ((pred (car rest)) (loop (cdr rest) (cons (car rest) out))) (else (loop (cdr rest) out)))))))src/folio/doc/transaction.sglmodified
txn-result-changed-span-start txn-result-changed-span-end txn-result-rebased? txn-result-rebase-base-revision txn-result-update-id txn-result-diff txn-result-recovery-actions ;; refusals txn-refusal? txn-refusal-reason txn-refusal-message txn-refusal-current-revision txn-refusal-target-context txn-refusal-diff txn-refusal-retryable?) txn-refusal-diff txn-refusal-retryable? txn-refusal-recovery-actions ;; --- the % surface (folio doc structural) builds on ------------------ ;; Additive, deliberately narrow: the reviewed F3a machinery is reused, ;; not refactored. Nothing here is for general callers. %txn-refuse %txn-stamp-recovery %txn-bounded-descriptor %txn-find-node %txn-node-context %txn-splice %txn-scalar->byte-offset %txn-publish! %txn-verify-staged %txn-make-line-diff %txn-require-contained-path %txn-require-safe-journal-text %txn-escape-string-literal %txn-body-insertion-point %txn-subtree-count %txn-subtree-declared-ids %txn-body-extent %txn-bytes-equal?) (begin ;; ------------------------------------------------------------------ ;; Result and refusal records ;; ------------------------------------------------------------------ ;; `recovery` (F3b): the crash-recovery actions the coordinator performed ;; before this transaction ran -- '() when there was nothing to recover. ;; Carried on results AND refusals so a self-heal is never silent. (define-struct txn-result-record (path) (node-id) (field) (old-revision) (new-revision) (old-value) (new-value) (span-start) (span-end) (rebased) (rebase-base-revision) (update-id) (diff)) (rebased) (rebase-base-revision) (update-id) (diff) (recovery)) (define (txn-result? v) (txn-result-record? v)) (define (txn-result-path r) (txn-result-record-path r)) (define (txn-result-rebase-base-revision r) (txn-result-record-rebase-base-revision r)) (define (txn-result-update-id r) (txn-result-record-update-id r)) (define (txn-result-diff r) (txn-result-record-diff r)) (define (txn-result-recovery-actions r) (txn-result-record-recovery r)) ;; A refusal is a RETURN VALUE, not a raise: it is the transaction doing ;; its job, and the caller must be able to read the fresh context without ;; exception plumbing. Raises are reserved for caller errors (malformed ;; arguments) and environment failures (lock, manifest, probe). (define-struct txn-refusal-record (reason) (message) (current-revision) (target-context) (diff) (retryable)) (reason) (message) (current-revision) (target-context) (diff) (retryable) (recovery)) (define (txn-refusal? v) (txn-refusal-record? v)) (define (txn-refusal-reason r) (txn-refusal-record-reason r)) (define (txn-refusal-target-context r) (txn-refusal-record-target-context r)) (define (txn-refusal-diff r) (txn-refusal-record-diff r)) (define (txn-refusal-retryable? r) (txn-refusal-record-retryable r)) (define (txn-refusal-recovery-actions r) (txn-refusal-record-recovery r)) (define (refuse reason message (keys: (current-revision #f) (target-context #f) (diff #f) (retryable #f))) (txn-refusal-record reason: reason message: message current-revision: current-revision target-context: target-context diff: diff retryable: retryable)) diff: diff retryable: retryable recovery: '())) ;; Doors stamp the coordinator's recovery report onto whatever the ;; transaction body returned. Records are immutable, so the stamp is a ;; reconstruction; '() stays '() and costs nothing to read. (define (stamp-recovery r actions) (cond ((null? actions) r) ((txn-result-record? r) (txn-result-record path: (txn-result-record-path r) node-id: (txn-result-record-node-id r) field: (txn-result-record-field r) old-revision: (txn-result-record-old-revision r) new-revision: (txn-result-record-new-revision r) old-value: (txn-result-record-old-value r) new-value: (txn-result-record-new-value r) span-start: (txn-result-record-span-start r) span-end: (txn-result-record-span-end r) rebased: (txn-result-record-rebased r) rebase-base-revision: (txn-result-record-rebase-base-revision r) update-id: (txn-result-record-update-id r) diff: (txn-result-record-diff r) recovery: actions)) ((txn-refusal-record? r) (txn-refusal-record reason: (txn-refusal-record-reason r) message: (txn-refusal-record-message r) current-revision: (txn-refusal-record-current-revision r) target-context: (txn-refusal-record-target-context r) diff: (txn-refusal-record-diff r) retryable: (txn-refusal-record-retryable r) recovery: actions)) (else r))) ;; ------------------------------------------------------------------ ;; The taxonomy, as data ;; update-child checks above only look where the request ;; pointed, and an injection lands precisely where nobody ;; points (the pre-review hunt's finding, closed as a class). ((not (= (descriptor-node-count staged-d) (+ pre-node-count node-delta))) ;; node-delta #f = the request's delta is inherently variable ;; (body replacement); the target-check then carries the ;; outside-the-subtree count invariant instead, so the class ;; the exact delta closes (injection where nobody points) ;; stays closed by a different instrument, never waived. ((and node-delta (not (= (descriptor-node-count staged-d) (+ pre-node-count node-delta)))) (refuse refusal-reason (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" (descriptor-node-count staged-d) (error "transaction: expected-revision must be \"sha256:\" + 64 lowercase hex digits over the raw file bytes")) (let ((path (require-contained-path coord path))) (coordinator-transact coord (lambda () (lambda (recovery-actions) (unless (file-exists? path) (error (format "transaction: no such file: ~a" path))) (stamp-recovery (let* ((bytes (read-file-bytes path)) (rev (revision-of-bytes bytes)) (source (utf8->string bytes))) (if (txn-refusal? pre) pre (attempt coord path bytes source rev stage #t expected-revision field node-id)))))))))))) #t expected-revision field node-id)))))))) recovery-actions))))) (define (attempt coord path bytes source rev stage rebased? base-revision field node-id) (let ((d (bounded-descriptor coord bytes source path rev "the note"))) rebased: rebased? rebase-base-revision: (and rebased? base-revision) update-id: update-id diff: (make-line-diff source staged-source)))))))))) diff: (make-line-diff source staged-source) recovery: '()))))))))) ;; ------------------------------------------------------------------ ;; Door 1: scalar field update (everything but status) (else #f))))))) ;; ------------------------------------------------------------------ ;; Door 3: body/structural requests -- classify and refuse (amendment 1) ;; Door 3: BODY REPLACEMENT (F3b) -- exact revision match only, NEVER ;; rebased (amendment 1 names body edits as the category whose mismatch ;; behaviour is refusal, and body prose is precisely where a stale rebase ;; silently destroys meaning; leader ruling 2026-08-25). ;; ;; F3b's scope is PROSE BODIES: a body that currently declares node ids ;; (journal entries, nested work items) refuses, and a replacement that ;; would introduce id-declaring nodes refuses at the postcheck -- ;; child-node surgery belongs to the structural ops, where each id change ;; is individually checked against its referrers. The body EXTENT is ;; parser-owned: the span from the first to the last of the node's ;; children (identical for braced and named-block spellings); an empty ;; body uses the journal insertion point with zero length. ;; ;; The exact node-count delta is inherently variable here, so the ;; injection-class instrument swaps: the postcheck asserts the node count ;; OUTSIDE the target's subtree is unchanged -- a `}` in the new text ;; that escaped the body would push former children out as siblings and ;; move that number. ;; ------------------------------------------------------------------ (define (subtree-count n) (let loop ((pending (list n)) (total 0)) (if (null? pending) total (loop (append (inspected-node-children (car pending)) (cdr pending)) (+ total 1))))) (define (subtree-declared-ids n) ;; Ids declared STRICTLY BELOW n (n's own id is the address, not body). (let loop ((pending (inspected-node-children n)) (ids '())) (cond ((null? pending) (reverse ids)) (else (let ((id (inspected-node-id (car pending)))) (loop (append (inspected-node-children (car pending)) (cdr pending)) (if id (cons id ids) ids))))))) (define (body-extent source node) ;; (start . end) in SCALAR offsets, or #f when the node has no body ;; delimiter at all. (let ((children (inspected-node-children node))) (if (pair? children) (let ((first-sp (inspected-node-span (car children))) (last-sp (inspected-node-span (list-ref children (- (length children) 1))))) (cons (span-scalar-start first-sp) (span-scalar-end last-sp))) (let ((at (update-insertion-point source node))) (and at (cons at at)))))) (define (folio-edit-body! coord path node-id new-text expected-revision) (unless (coordinator? coord) (error "folio-edit-body!: coord must be a coordinator")) (unless (string? new-text) (error "folio-edit-body!: new-text must be a string")) (let ((stage (lambda (d source rev) (stage-body-replace d source rev node-id new-text)))) (run-body-transaction coord path expected-revision stage node-id))) ;; Bodies get their own runner because their mismatch refusal has its own ;; ruled reason ('structural-revision-mismatch, amendment 1) -- reusing ;; run-transaction would report the scalar 'revision-mismatch and the two ;; classes must stay distinguishable (F3a gate arm a7 pins that). (define (run-body-transaction coord path expected-revision stage node-id) (unless (valid-revision? expected-revision) (error "folio-edit-body!: expected-revision must be a valid revision")) (let ((path (require-contained-path coord path))) (coordinator-transact coord (lambda () (lambda (recovery-actions) (unless (file-exists? path) (error (format "folio-edit-body!: no such file: ~a" path))) (stamp-recovery (let* ((bytes (read-file-bytes path)) (rev (revision-of-bytes bytes)) (source (utf8->string bytes))) (if (equal? rev expected-revision) (refuse 'structural-unimplemented "body and structural edits are F3b; this coordinator refuses them even on a clean revision match" current-revision: rev) (let* ((d (bounded-descriptor coord bytes source path rev "the note")) (context (and (descriptor? d) (let ((n (descriptor-node-by-id d node-id))) (and n (node-context source n)))))) (refuse 'structural-revision-mismatch (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." expected-revision rev) current-revision: rev target-context: context diff: (format "-expected-revision ~a~%+current-revision ~a~%" expected-revision rev))))))))) (cond ((not (bytes-equal? (string->utf8 source) bytes)) (refuse 'encoding-not-roundtrip "this file's bytes do not survive UTF-8 decode/encode; span-addressed editing would corrupt it, refusing" current-revision: rev)) ((equal? rev expected-revision) (attempt coord path bytes source rev stage #f expected-revision 'body node-id)) (else (let* ((d (bounded-descriptor coord bytes source path rev "the note")) (context (and (descriptor? d) (let ((n (descriptor-node-by-id d node-id))) (and n (node-context source n)))))) (refuse 'structural-revision-mismatch (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." expected-revision rev) current-revision: rev target-context: context diff: (format "-expected-revision ~a~%+current-revision ~a~%" expected-revision rev)))))) recovery-actions))))) (define (stage-body-replace d source rev node-id new-text) (let ((node (find-node d node-id rev))) (if (txn-refusal? node) node (let ((declared (subtree-declared-ids node))) (cond ((pair? declared) (refuse 'body-declares-ids (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)." node-id declared) current-revision: rev target-context: (node-context source node))) (else (let ((extent (body-extent source node))) (if (not extent) (refuse 'update-target-has-no-body (format "node ~s has neither a braced body nor an @end block to replace into" node-id) current-revision: rev) (let* ((start (car extent)) (end (cdr extent)) (old-body (substring source start end)) (staged (splice source start end new-text)) (byte-start (scalar->byte-offset source start)) (byte-end (scalar->byte-offset source end)) (kind (inspected-node-name node)) (pre-total (descriptor-node-count d)) (pre-subtree (subtree-count node)) (pre-fields (map (lambda (f) (cons (field-name f) (field-value f))) (inspected-node-fields node)))) (list staged byte-start (- byte-end byte-start) (bytevector-length (string->utf8 new-text)) old-body new-text #f #f ;; node delta: variable; the check below ;; carries the outside-subtree invariant (lambda (staged-d) (body-replace-check staged-d node-id kind pre-fields pre-total pre-subtree rev)))))))))))) (define (body-replace-check staged-d node-id kind pre-fields pre-total pre-subtree rev) (let ((n2 (descriptor-node-by-id staged-d node-id))) (cond ((not n2) (refuse 'postcheck-failed (format "the target node ~s vanished from the staged document" node-id) current-revision: rev)) ((not (eq? (inspected-node-name n2) kind)) (refuse 'postcheck-failed (format "the staged node ~s changed kind (~a -> ~a)" node-id kind (inspected-node-name n2)) current-revision: rev)) ((not (equal? pre-fields (map (lambda (f) (cons (field-name f) (field-value f))) (inspected-node-fields n2)))) (refuse 'postcheck-failed (format "the staged node ~s's argument fields moved; a body replacement must not touch them" node-id) current-revision: rev)) ((pair? (subtree-declared-ids n2)) (refuse 'body-would-declare-ids (format "the replacement body declares node id(s) ~a; body replacement is for PROSE -- use the structural operations to add id-bearing nodes" (subtree-declared-ids n2)) current-revision: rev)) ;; The escape detector: nodes OUTSIDE the target's subtree must be ;; exactly as numerous as before. A brace escaping the body pushes ;; former body content out as siblings and moves this number. ((not (= (- (descriptor-node-count staged-d) (subtree-count n2)) (- pre-total pre-subtree))) (refuse 'postcheck-failed (format "the replacement body escaped its extent: ~a nodes now live outside ~s's subtree where ~a did before" (- (descriptor-node-count staged-d) (subtree-count n2)) node-id (- pre-total pre-subtree)) current-revision: rev)) (else #f)))) ;; ------------------------------------------------------------------ ;; Door 2b: append-only @update without a status change current-revision: rev)) (else #f))))))))))))) ;; ------------------------------------------------------------------ ;; The % surface: aliases binding the module-private machinery for ;; (folio doc structural). Aliased rather than renamed so every F3a ;; call site and comment keeps its reviewed spelling. ;; ------------------------------------------------------------------ (define %txn-refuse refuse) (define %txn-stamp-recovery stamp-recovery) (define %txn-bounded-descriptor bounded-descriptor) (define %txn-find-node find-node) (define %txn-node-context node-context) (define %txn-splice splice) (define %txn-scalar->byte-offset scalar->byte-offset) (define %txn-publish! publish!) (define %txn-verify-staged verify-staged) (define %txn-make-line-diff make-line-diff) (define %txn-require-contained-path require-contained-path) (define %txn-require-safe-journal-text require-safe-journal-text) (define %txn-escape-string-literal escape-string-literal) (define %txn-body-insertion-point update-insertion-point) (define %txn-subtree-count subtree-count) (define %txn-subtree-declared-ids subtree-declared-ids) (define %txn-body-extent body-extent) (define %txn-bytes-equal? bytes-equal?) ;; Count a node's direct `update` children. Shared by the two journal ;; target-checks: "an update child EXISTS" is satisfied by any prior ;; journal entry, so the check that OUR entry landed is a COUNT increasetest/f3a-txn-probe.sglmodified
(assert-refusal "scalar-but-not-allowlisted on mismatch" title 'revision-mismatch) (assert-true "the two refusals are distinguishable" (not (eq? (txn-refusal-reason body) (txn-refusal-reason title)))))) (assert-refusal "body edit on a CLEAN match still refuses (F3b)" ;; F3b: body edits WORK on a clean match now (Q1 ruling) -- but t-alpha ;; carries journal children by this point in the battery, and an ;; id-declaring body refuses wholesale replacement: deleting ids blind to ;; their referrers is the structural ops' job to do safely. (assert-refusal "body edit on an id-declaring body refuses (F3b scope)" (folio-edit-body! coord note "t-alpha" "replacement body" (rev-of note)) 'structural-unimplemented))) 'body-declares-ids)));; ---------------------------------------------------------------------------;; a-aba -- ABA on an allowlisted field, accepted explicitly: the file went;; ---------------------------------------------------------------------------(arm "a-manifest" (lambda () ;; F3b: a manifest that PARSES triggers automatic recovery (the F3b probe ;; covers those branches); this arm keeps the UNPROVABLE case pinned -- a ;; manifest that does not parse must refuse every mutation to a HUMAN, ;; never guess, and its removal stands the coordinator back up. (let ((manifest (path-join root transaction-manifest-name))) (write-file-string manifest "left by a crashed cross-file transaction") (let ((raised (guard (e (#t (if (exception? e) (exception-message e) "raised"))) (folio-update-field! coord note "t-alpha" 'priority 'low (rev-of note)) #f))) (assert-true "transaction refused while a manifest exists" (string? raised)) (assert-true "the refusal names the manifest and F3b" (and (string-contains? raised "manifest") (string-contains? raised "F3b")))) (assert-true "transaction refused while an unparseable manifest exists" (string? raised)) (assert-true "the refusal names the manifest and demands a human" (and (string-contains? raised "manifest") (string-contains? raised "human")))) (delete-file manifest) (assert-result "removal stands the coordinator back up" (folio-update-field! coord note "t-alpha" 'priority 'low (rev-of note)