F3b: move, same-file and cross-file, with reference re-addressing
folio-move-node! moves a work item. Same-file: extract + reinsert in one staged document; no reference is touched because D12 references address document + node and neither changes. Cross-file: the moved node LEAVES its document, so every related/transclude pairing the moved id with the source document would silently go stale (t-5e4329's class) -- those document: fields are rewritten to the destination in the SAME manifest transaction, with rename's absolute raw-accounting refusal for anything unrewritable, and the moved subtree's ids must be fresh in the destination.
Two defects the smoke's own fixtures caught before any gate existed: the inserted node abutted @end note without a newline (the staged reparse refused it -- the postcheck defending as designed), and the first document-identity derivation used the FILESYSTEM slug where a reference names the D12 DOCUMENT ADDRESS (the owning root's declared id); the watcher fixture refused the move on the mismatch immediately. Both spellings now derive from the descriptor (owning-root-id), and a root with no declared id refuses rather than guessing.
F3a battery 22/22 green.
src/folio/doc/structural.sgl | 400 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 398 insertions(+), 2 deletions(-)src/folio/doc/structural.sglmodified
current-revision: rev)) (else #f))) (define (folio-move-node! . args) (error "folio-move-node!: landing in the next F3b slice")))) ;; ------------------------------------------------------------------ ;; MOVE ;; ;; Same-file: one staged document, extract + reinsert, no reference is ;; affected (D12 references address document + node, and neither ;; changes). Cross-file: the moved node LEAVES its document, so every ;; `related`/`transclude` whose `document:` names the source AND whose ;; `node:` names the moved id would silently go stale -- exactly ;; t-5e4329's class -- so those `document:` fields are rewritten to the ;; destination's slug in the same manifest transaction, with the same ;; raw-accounting refusal as rename for anything unrewritable. ;; ------------------------------------------------------------------ (define (folio-move-node! coord source-path node-id dest-path dest-container-id expected-source-revision (keys: (expected-dest-revision #f))) (unless (coordinator? coord) (error "folio-move-node!: coord must be a coordinator")) (let ((source-path (%txn-require-contained-path coord source-path)) (dest-path (%txn-require-contained-path coord dest-path))) (coordinator-transact coord (lambda (recovery-actions) (%txn-stamp-recovery (if (equal? source-path dest-path) (move-same-file coord source-path node-id dest-container-id expected-source-revision) (move-cross-file coord source-path node-id dest-path dest-container-id expected-source-revision expected-dest-revision)) recovery-actions))))) (define (read-doc coord path label) ;; (list bytes text rev descriptor) or a refusal. (if (not (file-exists? path)) (%txn-refuse 'unknown-node (format "no such file: ~a" path)) (let* ((bytes (read-file-bytes path)) (text (utf8->string bytes)) (rev (revision-of-bytes bytes))) (if (not (%txn-bytes-equal? (string->utf8 text) bytes)) (%txn-refuse 'encoding-not-roundtrip (format "~a's bytes do not survive UTF-8 round-trip" path) current-revision: rev) (let ((d (%txn-bounded-descriptor coord bytes text path rev label))) (if (txn-refusal? d) d (list bytes text rev d))))))) (define (movable-node d text node-id rev) ;; The node, checked movable (work item), or a refusal. (let ((node (find-container d text node-id rev))) (cond ((txn-refusal? node) node) ((not (memq (inspected-node-name node) work-item-kinds)) (%txn-refuse 'scoped-out (format "moving a ~a is SCOPED OUT of F3b by ruling (2026-08-25), not unsupported -- F3b moves work items" (inspected-node-name node)) current-revision: rev)) (else node)))) (define (move-same-file coord path node-id dest-container-id expected-revision) (let ((doc (read-doc coord path (format "document ~a" (path-basename path))))) (if (txn-refusal? doc) doc (let ((bytes (list-ref doc 0)) (text (list-ref doc 1)) (rev (list-ref doc 2)) (d (list-ref doc 3))) (cond ((not (equal? rev expected-revision)) (%txn-refuse 'structural-revision-mismatch (format "expected ~a at revision ~a but it is at ~a; structural edits never rebase (amendment 1)" path expected-revision rev) current-revision: rev)) (else (let ((node (movable-node d text node-id rev))) (if (txn-refusal? node) node (let ((dest (find-container d text dest-container-id rev))) (cond ((txn-refusal? dest) dest) ((not (memq (inspected-node-name dest) container-kinds-for-insert)) (%txn-refuse 'scoped-out (format "moving into a ~a is SCOPED OUT of F3b by ruling" (inspected-node-name dest)) current-revision: rev)) (else (let* ((sp (inspected-node-span node)) (del-start (span-scalar-start sp)) (del-end (span-scalar-end sp)) (moved-text (substring text del-start del-end)) (insert-at (%txn-body-insertion-point text dest))) (cond ((not insert-at) (%txn-refuse 'update-target-has-no-body (format "container ~s has no body to move into" dest-container-id) current-revision: rev)) ((and (>= insert-at del-start) (<= insert-at del-end)) (%txn-refuse 'move-into-self (format "~s cannot be moved into its own extent" node-id) current-revision: rev)) (else (let* ((after-delete (%txn-splice text del-start del-end "")) (adj (if (> insert-at del-end) (- insert-at (- del-end del-start)) insert-at)) (staged (%txn-splice after-delete adj adj (string-append "\n" moved-text "\n"))) (lo (if (< adj del-start) adj del-start)) (hi-scalar (if (> del-end insert-at) del-end insert-at)) (byte-lo (%txn-scalar->byte-offset text lo)) (byte-hi (%txn-scalar->byte-offset text hi-scalar)) (bad (%txn-verify-staged coord path bytes text rev staged (descriptor-ids d) byte-lo (- byte-hi byte-lo) 0 (lambda (staged-d) (move-check staged-d node-id dest-container-id rev)) 'postcheck-failed (descriptor-node-count d) #f '()))) (if bad bad (let* ((staged-bytes (string->utf8 staged)) (new-rev (revision-of-bytes staged-bytes))) (%txn-publish! path staged-bytes) (sop-result-record op: 'move-node files: (list (sop-file path rev new-rev (%txn-make-line-diff text staged))) node-id: node-id detail: (format "moved ~s into ~s (same file)" node-id dest-container-id) recovery: '())))))))))))))))))) (define (move-check staged-d node-id dest-container-id rev) (let ((c2 (let walk ((pending (descriptor-nodes staged-d))) (cond ((null? pending) #f) ((node-carries-id? (car pending) dest-container-id) (car pending)) (else (walk (append (inspected-node-children (car pending)) (cdr pending)))))))) (cond ((not (eq? (descriptor-id-status staged-d node-id) 'unique)) (%txn-refuse 'postcheck-failed (format "~s is not uniquely declared after the move" node-id) current-revision: rev)) ((not c2) (%txn-refuse 'postcheck-failed (format "destination container ~s vanished" dest-container-id) current-revision: rev)) ((not (let walk ((pending (inspected-node-children c2))) (cond ((null? pending) #f) ((equal? (inspected-node-id (car pending)) node-id) #t) (else (walk (append (inspected-node-children (car pending)) (cdr pending))))))) (%txn-refuse 'postcheck-failed (format "~s did not land inside ~s" node-id dest-container-id) current-revision: rev)) (else #f)))) (define (move-cross-file coord source-path node-id dest-path dest-container-id expected-source-revision expected-dest-revision) (let ((sdoc (read-doc coord source-path "the source document"))) (if (txn-refusal? sdoc) sdoc (let ((ddoc (read-doc coord dest-path "the destination document"))) (if (txn-refusal? ddoc) ddoc (let ((s-bytes (list-ref sdoc 0)) (s-text (list-ref sdoc 1)) (s-rev (list-ref sdoc 2)) (s-d (list-ref sdoc 3)) (d-bytes (list-ref ddoc 0)) (d-text (list-ref ddoc 1)) (d-rev (list-ref ddoc 2)) (d-d (list-ref ddoc 3))) (cond ((not (equal? s-rev expected-source-revision)) (%txn-refuse 'structural-revision-mismatch (format "source ~a moved past the expected revision; structural edits never rebase" source-path) current-revision: s-rev)) ((and expected-dest-revision (not (equal? d-rev expected-dest-revision))) (%txn-refuse 'structural-revision-mismatch (format "destination ~a moved past the expected revision" dest-path) current-revision: d-rev)) (else (let ((node (movable-node s-d s-text node-id s-rev))) (if (txn-refusal? node) node (move-cross-with-node coord source-path dest-path dest-container-id node-id node s-bytes s-text s-rev s-d d-bytes d-text d-rev d-d))))))))))) (define (move-cross-with-node coord source-path dest-path dest-container-id node-id node s-bytes s-text s-rev s-d d-bytes d-text d-rev d-d) (let* ((moved-ids (cons node-id (filter string? (%txn-subtree-declared-ids node)))) (dest (find-container d-d d-text dest-container-id d-rev))) (cond ((txn-refusal? dest) dest) ((not (memq (inspected-node-name dest) container-kinds-for-insert)) (%txn-refuse 'scoped-out (format "moving into a ~a is SCOPED OUT of F3b by ruling" (inspected-node-name dest)) current-revision: d-rev)) ;; The moved ids must be fresh in the destination document. ((let lp ((ids moved-ids)) (cond ((null? ids) #f) ((not (eq? (descriptor-id-status d-d (car ids)) 'absent)) (car ids)) (else (lp (cdr ids))))) => (lambda (id) (%txn-refuse 'insert-id-taken (format "id ~s already exists in the destination document" id) current-revision: d-rev))) (else ;; Referrer accounting root-wide: every occurrence of the moved ;; ids must be a rewritable document-scoped reference, the ;; declaration itself, or nothing -- the same absolute refusal ;; discipline as rename. (let* ((root (coordinator-root coord)) (population (root-population coord)) (entries (cdr population)) (found (find-referrers coord entries moved-ids)) (semantic (list-ref found 0)) (raw-only (list-ref found 1)) (troubles (list-ref found 2))) (cond ((pair? troubles) (%txn-refuse 'referrer-uninspectable (format "cannot account for every occurrence of ~a: ~a" moved-ids troubles) retryable: #t)) ((pair? raw-only) (%txn-refuse 'unrewritable-reference (format "moving ~s would strand raw occurrence(s): ~a -- refused, no override" node-id raw-only))) (else (stage-cross-move coord root entries source-path dest-path dest-container-id dest node-id node semantic s-bytes s-text s-rev s-d d-bytes d-text d-rev d-d)))))))) (define (stage-cross-move coord root entries source-path dest-path dest-container-id dest node-id node semantic s-bytes s-text s-rev s-d d-bytes d-text d-rev d-d) (let* ((source-rel (path-relative root source-path)) (dest-rel (path-relative root dest-path)) ;; The document identity a reference names is the D12 DOCUMENT ;; ADDRESS -- the declared id of the ROOT NODE owning the moved ;; node (`@note["topics/f3a-fixture" ...]`), NOT the file's slug. ;; The first spelling here used the filesystem slug and the ;; smoke's own watcher fixture refuted it immediately. (source-slug (owning-root-id s-d node)) (dest-slug (owning-root-id d-d dest)) (sp (inspected-node-span node)) (del-start (span-scalar-start sp)) (del-end (span-scalar-end sp)) (moved-text (substring s-text del-start del-end)) (insert-at (%txn-body-insertion-point d-text dest))) (cond ((not insert-at) (%txn-refuse 'update-target-has-no-body (format "container ~s has no body to move into" dest-container-id) current-revision: d-rev)) ((not source-slug) (%txn-refuse 'postcheck-failed "the moved node's owning root declares no document id; its references cannot be re-addressed" current-revision: s-rev)) ((not dest-slug) (%txn-refuse 'postcheck-failed "the destination's owning root declares no document id; references to the moved node could not name it there" current-revision: d-rev)) (else (let* ((staged-source (%txn-splice s-text del-start del-end "")) (staged-dest (%txn-splice d-text insert-at insert-at (string-append "\n" moved-text "\n"))) ;; Rewrite each referrer's `document:` field where it names ;; the SOURCE slug for one of the moved ids (a reference in ;; the source or dest document themselves is handled on the ;; already-staged text below, so those are excluded here). (ref-files (referrer-document-rewrites entries semantic source-rel dest-rel source-slug dest-slug)) (staged-list (append (list (list source-rel source-path s-bytes staged-source) (list dest-rel dest-path d-bytes staged-dest)) (if (txn-refusal? ref-files) '() ref-files)))) (if (txn-refusal? ref-files) ref-files ;; Verify: source lost the ids, dest gained them uniquely. (let ((bad (or (verify-move-side coord source-rel s-bytes s-text s-rev s-d staged-source node-id 'gone (cons node-id (filter string? (%txn-subtree-declared-ids node)))) (verify-move-side coord dest-rel d-bytes d-text d-rev d-d staged-dest node-id 'present '())))) (if bad bad (let* ((txn-id (content-txn-id "move-node" (list node-id source-rel dest-rel s-rev))) (published (publish-file-set! coord txn-id "move-node" staged-list))) (sop-result-record op: 'move-node files: (map (lambda (p) (sop-file (list-ref p 0) (list-ref p 1) (list-ref p 2) #f)) published) node-id: node-id detail: (format "moved ~s from ~a to ~a (container ~s); ~a referrer file(s) redirected" node-id source-rel dest-rel dest-container-id (- (length staged-list) 2)) recovery: '())))))))))) ;; The id of the top-level root whose subtree holds `node` (by identity). ;; #f when the root declares no id -- callers refuse on it. (define (owning-root-id d node) (let roots ((rest (descriptor-nodes d))) (cond ((null? rest) #f) ((let walk ((pending (list (car rest)))) (cond ((null? pending) #f) ((eq? (car pending) node) #t) (else (walk (append (inspected-node-children (car pending)) (cdr pending)))))) (inspected-node-id (car rest))) (else (roots (cdr rest)))))) (define (strip-format-extension rel) (let ((n (string-length rel))) (cond ((and (> n 4) (equal? (substring rel (- n 4) n) ".sgd")) (substring rel 0 (- n 4))) ((and (> n 3) (equal? (substring rel (- n 3) n) ".md")) (substring rel 0 (- n 3))) (else rel)))) ;; Build staged rewrites of referrer files' `document:` fields. Returns ;; a list of (rel abs pre-bytes staged-text) or a refusal. (define (referrer-document-rewrites entries semantic source-rel dest-rel source-slug dest-slug) (let loop ((rels (let dedupe ((rest semantic) (out '())) (cond ((null? rest) (reverse out)) ((or (equal? (car (car rest)) source-rel) (equal? (car (car rest)) dest-rel) (member (car (car rest)) out)) (dedupe (cdr rest) out)) (else (dedupe (cdr rest) (cons (car (car rest)) out)))))) (out '())) (if (null? rels) (reverse out) (let* ((rel (car rels)) (entry (assoc-entry entries rel)) (bytes (list-ref entry 3)) (text (utf8->string bytes)) (sites (let lp ((rest semantic) (acc '())) (cond ((null? rest) (reverse acc)) ((equal? (car (car rest)) rel) (lp (cdr rest) (cons (cdr (car rest)) acc))) (else (lp (cdr rest) acc))))) ;; Each site: (node field) -- rewrite the sibling ;; `document` field when it names the source slug. (doc-spans (let lp ((rest sites) (acc '())) (if (null? rest) (reverse acc) (let* ((n (list-ref (car rest) 0)) (df (let f ((fs (inspected-node-fields n))) (cond ((null? fs) #f) ((eq? (field-name (car fs)) 'document) (car fs)) (else (f (cdr fs))))))) (cond ((not df) (lp (cdr rest) acc)) ((not (equal? (field-value df) source-slug)) ;; Points at a different document carrying the ;; same id -- impossible under the freshness ;; checks above, refused defensively. (%txn-refuse 'postcheck-failed (format "a reference in ~a pairs the moved id with document ~s, not the source ~s" rel (field-value df) source-slug))) ((not (field-span df)) (%txn-refuse 'rebase-span-invalid (format "a document: field in ~a has no span" rel))) (else (lp (cdr rest) (cons (field-span df) acc))))))))) (if (txn-refusal? doc-spans) doc-spans (let* ((replacement (%txn-escape-string-literal dest-slug)) (ordered (sort-spans-desc doc-spans)) (staged (let sp ((rest ordered) (s text)) (if (null? rest) s (sp (cdr rest) (%txn-splice s (span-scalar-start (car rest)) (span-scalar-end (car rest)) replacement)))))) (loop (cdr rels) (cons (list rel (list-ref entry 1) bytes staged) out)))))))) (define (verify-move-side coord rel bytes text rev d staged node-id expect allowed-lost) (%txn-verify-staged coord rel bytes text rev staged (descriptor-ids d) 0 (bytevector-length bytes) 0 (lambda (staged-d) (cond ((and (eq? expect 'gone) (not (eq? (descriptor-id-status staged-d node-id) 'absent))) (%txn-refuse 'postcheck-failed (format "~s still in the source after the move" node-id) current-revision: rev)) ((and (eq? expect 'present) (not (eq? (descriptor-id-status staged-d node-id) 'unique))) (%txn-refuse 'postcheck-failed (format "~s not uniquely present in the destination" node-id) current-revision: rev)) (else #f))) 'postcheck-failed (descriptor-node-count d) #f allowed-lost))))