F3b: rename-id with reference rewrite, and the cross-file engine
The sharp one. folio-rename-id! renames a node id across every document that references it, all-or-nothing:
- The NEW id must be completely fresh: any identifier-boundary occurrence anywhere in the root -- prose included -- refuses 'rename-target-taken.
- Exactly ONE document may declare the old id; a workspace-ambiguous id refuses 'ambiguous-rename rather than picking (D9 presumes uniqueness and the estate has live duplicates).
- The OWNING document's revision must match exactly; structural edits never rebase (amendment 1).
- Every raw occurrence must be accounted for by a rewritable span (related/transclude node: fields, the owner's declaration) -- an occurrence in prose, code, a query spec, an uninspectable file, or a Markdown note refuses the WHOLE rename with no override: renaming around it would leave it stale, t-5e4329's defect class, and rewriting it blind is worse.
- Every touched file is staged, verified through the full single-file pipeline (allowed-lost carries the old id), then GRAPH CLOSURE: zero boundary occurrences of the old id across the staged set.
publish-file-set! is the shared cross-file engine: hash-verified backups, hash-verified staged temps, the ATOMIC manifest write as the commit point, renames, manifest cleared, backups dropped. A crash before the manifest = debris the next writer sweeps; after = a validated set the next writer completes. The txn id derives from the operation's own content -- no clock, no randomness, reproducible in transcripts.
Smoked: a 3-file rename (owner + 1-site and 2-site referrers) published atomically with zero old-id residue and no debris; the five refusal classes (prose occurrence, markdown occurrence, taken target, stale owner revision, unknown id) each fire with their own reason. Toolchain note: this sigil's map is single-list; the result zip is a manual loop.
src/folio/doc/structural.sgl | 320 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 316 insertions(+), 4 deletions(-)src/folio/doc/structural.sglmodified
((pred (car rest)) (loop (cdr rest) (cons (car rest) out))) (else (loop (cdr rest) out))))) ;; Placeholders wired in the next slice; declared so the export list is ;; honest from the first commit. (define (folio-rename-id! . args) (error "folio-rename-id!: landing in the next F3b slice")) ;; ------------------------------------------------------------------ ;; THE CROSS-FILE PUBLICATION ENGINE ;; ;; Order is the contract (see manifest.sgl's header): stage everything, ;; VERIFY everything, then backups -> temps -> MANIFEST (the atomic ;; commit point) -> renames -> clear manifest -> drop backups. A crash ;; before the manifest lands leaves only debris the next writer's ;; recovery sweeps (nothing was renamed); a crash after it leaves a ;; validated set the next writer COMPLETES. The txn id is derived from ;; the operation's own content -- this toolchain deliberately exposes no ;; clock or randomness to library code, and a content-derived id is ;; reproducible in transcripts. ;; ------------------------------------------------------------------ (define (content-txn-id op-label parts) (substring (revision-hex (revision-of-bytes (string->utf8 (apply string-append op-label parts)))) 0 12)) ;; staged-list entries: (list rel-path abs-path pre-bytes staged-source) ;; Returns per-file (list rel pre-rev post-rev) after full publication. (define (publish-file-set! coord txn-id op-label staged-list) (let* ((root (coordinator-root coord)) (entries (map (lambda (s) (let* ((rel (list-ref s 0)) (abs (list-ref s 1)) (pre-bytes (list-ref s 2)) (staged-bytes (string->utf8 (list-ref s 3))) (dir (path-dirname abs)) (base (path-basename abs)) (temp-name (string-append staging-prefix base "." txn-id ".tmp")) (backup-name (string-append backup-prefix base "." txn-id)) (pre-rev (revision-of-bytes pre-bytes)) (post-rev (revision-of-bytes staged-bytes))) ;; Backups first, temps second, both hash-verified: ;; an unverified restore artifact is the thing the ;; estate's sabotage-restore discipline exists for. (write-file-bytes (path-join dir backup-name) pre-bytes) (unless (equal? (revision-of-file (path-join dir backup-name)) pre-rev) (error "publish-file-set!: backup hash mismatch after write")) (write-file-bytes (path-join dir temp-name) staged-bytes) (unless (equal? (revision-of-file (path-join dir temp-name)) post-rev) (error "publish-file-set!: staged temp hash mismatch after write")) (list rel abs pre-rev post-rev (path-relative root (path-join dir temp-name)) (path-relative root (path-join dir backup-name))))) staged-list))) ;; THE COMMIT POINT. (write-transaction-manifest! root txn-id op-label (map (lambda (e) (mentry (list-ref e 0) (list-ref e 2) (list-ref e 4) (list-ref e 3) (list-ref e 3) (list-ref e 5) (list-ref e 2))) entries)) ;; Renames. From here a crash is COMPLETED by recovery. (for-each (lambda (e) (rename-file (path-join root (list-ref e 4)) (list-ref e 1))) entries) (clear-transaction-manifest! root) (for-each (lambda (e) (guard (x (#t #f)) (delete-file (path-join root (list-ref e 5))))) entries) (map (lambda (e) (list (list-ref e 0) (list-ref e 2) (list-ref e 3))) entries))) ;; ------------------------------------------------------------------ ;; RENAME ID WITH REFERENCE REWRITE ;; ------------------------------------------------------------------ (define (folio-rename-id! coord old-id new-id expected-revision) (unless (coordinator? coord) (error "folio-rename-id!: coord must be a coordinator")) (unless (and (string? old-id) (valid-node-id? old-id)) (error "folio-rename-id!: old-id must use flat node-id syntax")) (unless (and (string? new-id) (valid-node-id? new-id)) (error "folio-rename-id!: new-id must use flat node-id syntax")) (when (equal? old-id new-id) (error "folio-rename-id!: old and new ids are identical")) (coordinator-transact coord (lambda (recovery-actions) (%txn-stamp-recovery (rename-under-lock coord old-id new-id expected-revision) recovery-actions)))) (define (rename-under-lock coord old-id new-id expected-revision) (let* ((population (root-population coord)) (census (car population)) (entries (cdr population))) ;; 1. The NEW id must be completely fresh -- no boundary occurrence ;; anywhere, prose included. Conservative by design. (let ((taken (filter (lambda (e) (and (list-ref e 3) (pair? (boundary-occurrences (utf8->string (list-ref e 3)) new-id)))) entries))) (if (pair? taken) (%txn-refuse 'rename-target-taken (format "new id ~s already occurs in: ~a -- a rename must land in a fresh name" new-id (map car taken))) ;; 2. Exactly ONE document declares the old id (D9 workspace ;; uniqueness; the estate's live duplicates refuse here rather ;; than being picked between). (let ((owners (filter (lambda (e) (and (eq? (list-ref e 2) 'sgd) (list-ref e 3) (let* ((text (utf8->string (list-ref e 3))) (hits (boundary-occurrences text old-id))) (and (pair? hits) (let ((d (%txn-bounded-descriptor coord (list-ref e 3) text (list-ref e 0) (revision-of-bytes (list-ref e 3)) (format "document ~a" (list-ref e 0))))) (and (descriptor? d) (pair? (declaration-sites d (list old-id))))))))) entries))) (cond ((null? owners) (%txn-refuse 'unknown-node (format "no document in this root declares node id ~s" old-id))) ((pair? (cdr owners)) (%txn-refuse 'ambiguous-rename (format "node id ~s is declared in ~a documents (~a); a workspace-ambiguous id cannot be renamed -- D9 presumes uniqueness and this coordinator refuses to pick" old-id (length owners) (map car owners)))) (else (rename-with-owner coord old-id new-id expected-revision entries (car owners))))))))) (define (rename-with-owner coord old-id new-id expected-revision entries owner) (let* ((owner-rel (list-ref owner 0)) (owner-bytes (list-ref owner 3)) (owner-rev (revision-of-bytes owner-bytes))) (cond ;; 3. Structural: exact revision match on the OWNING document, ;; never rebased (amendment 1). ((not (equal? owner-rev expected-revision)) (%txn-refuse 'structural-revision-mismatch (format "expected the owning document ~a at revision ~a but it is at ~a. Structural edits ALWAYS refuse on revision mismatch (D11 amendment 1); reread and re-decide." owner-rel expected-revision owner-rev) current-revision: owner-rev diff: (format "-expected-revision ~a~%+current-revision ~a~%" expected-revision owner-rev))) (else ;; 4. Referrer accounting: every raw occurrence must be a ;; rewritable span or a declaration; anything else -- prose, ;; code, query specs, Markdown notes, uninspectable files -- ;; refuses the WHOLE rename, absolutely. (let* ((found (find-referrers coord entries (list old-id))) (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 ~s: ~a file(s) could not be inspected: ~a" old-id (length troubles) troubles) retryable: #t)) ((pair? raw-only) (%txn-refuse 'unrewritable-reference (format "~s occurs where no rewritable span exists -- ~a -- renaming would leave those stale (t-5e4329's defect class) or rewrite them blind; both refuse, with no override" old-id raw-only))) (else (stage-rename-set coord old-id new-id entries owner semantic)))))))) ;; Stage every touched file, verify each, publish through the engine. (define (stage-rename-set coord old-id new-id entries owner semantic) (let* ((root (coordinator-root coord)) ;; Group semantic reference sites by file; the OWNER also gets ;; its declaration site(s) rewritten. (touched-rels (let loop ((rest semantic) (out (list (list-ref owner 0)))) (cond ((null? rest) (reverse out)) ((member (car (car rest)) out) (loop (cdr rest) out)) (else (loop (cdr rest) (cons (car (car rest)) out)))))) (staged-or-refusal (let loop ((rels touched-rels) (staged '())) (if (null? rels) (reverse staged) (let ((s (stage-one-rename-file coord old-id new-id entries (car rels) semantic))) (if (txn-refusal? s) s (loop (cdr rels) (cons s staged)))))))) (if (txn-refusal? staged-or-refusal) staged-or-refusal ;; GRAPH CLOSURE over the staged set: zero boundary occurrences ;; of the old id remain anywhere it occurred. (let ((leak (filter (lambda (s) (pair? (boundary-occurrences (list-ref s 3) old-id))) staged-or-refusal))) (if (pair? leak) (%txn-refuse 'postcheck-failed (format "rename closure failed: ~s still occurs in staged ~a" old-id (map car leak))) (let* ((txn-id (content-txn-id "rename-id" (list old-id new-id (revision-of-bytes (list-ref owner 3))))) (published (publish-file-set! coord txn-id "rename-id" staged-or-refusal))) (sop-result-record op: 'rename-id files: (let zip ((ps published) (ss staged-or-refusal) (out '())) (if (or (null? ps) (null? ss)) (reverse out) (let ((p (car ps)) (s (car ss))) (zip (cdr ps) (cdr ss) (cons (sop-file (list-ref p 0) (list-ref p 1) (list-ref p 2) (%txn-make-line-diff (utf8->string (list-ref (assoc-entry entries (list-ref p 0)) 3)) (list-ref s 3))) out))))) node-id: new-id detail: (format "renamed ~s -> ~s across ~a file(s); ~a reference site(s) rewritten" old-id new-id (length touched-rels) (length semantic)) recovery: '()))))))) (define (assoc-entry entries rel) (let loop ((rest entries)) (cond ((null? rest) #f) ((equal? (car (car rest)) rel) (car rest)) (else (loop (cdr rest)))))) ;; Stage one file of the rename: rewrite its declaration site(s) (owner) ;; and reference site(s), rear-most span first so earlier offsets stay ;; valid; verify with the full single-file pipeline. (define (stage-one-rename-file coord old-id new-id entries rel semantic) (let* ((entry (assoc-entry entries rel)) (bytes (list-ref entry 3)) (text (utf8->string bytes)) (rev (revision-of-bytes bytes)) (d (%txn-bounded-descriptor coord bytes text rel rev (format "document ~a" rel)))) (if (txn-refusal? d) d (let* ((decl (declaration-sites d (list old-id))) (refs (let loop ((rest semantic) (out '())) (cond ((null? rest) (reverse out)) ((equal? (car (car rest)) rel) (loop (cdr rest) (cons (cdr (car rest)) out))) (else (loop (cdr rest) out))))) ;; Spans to rewrite: each declaration node's ID FIELD (the ;; positional first argument) and each reference site's ;; node field. All must have spans or the file refuses. (spans (append (map (lambda (n) (let lp ((fs (inspected-node-fields n))) (cond ((null? fs) #f) ((and (not (field-name (car fs))) (equal? (field-value (car fs)) old-id)) (field-span (car fs))) (else (lp (cdr fs)))))) decl) (map (lambda (site) (field-span (list-ref site 1))) refs)))) (if (memq #f spans) (%txn-refuse 'rebase-span-invalid (format "a rewrite site for ~s in ~a has no recorded span; refusing a blind rewrite" old-id rel) current-revision: rev) (let* ((replacement (%txn-escape-string-literal new-id)) (ordered (sort-spans-desc spans)) (staged (let loop ((rest ordered) (s text)) (if (null? rest) s (loop (cdr rest) (%txn-splice s (span-scalar-start (car rest)) (span-scalar-end (car rest)) replacement))))) ;; Verify through the standard pipeline with a ;; coarse byte region spanning all rewrites. (byte-lo (%txn-scalar->byte-offset text (span-scalar-start (list-ref ordered (- (length ordered) 1))))) (byte-hi (%txn-scalar->byte-offset text (span-scalar-end (car ordered)))) (bad (%txn-verify-staged coord rel bytes text rev staged (descriptor-ids d) byte-lo (- byte-hi byte-lo) 0 (lambda (staged-d) (rename-file-check staged-d rel old-id new-id (pair? decl) rev)) 'postcheck-failed (descriptor-node-count d) 0 (list old-id)))) (if bad bad (list rel (list-ref entry 1) bytes staged)))))))) (define (sort-spans-desc spans) ;; Insertion sort, descending by scalar start; site counts are small. (let loop ((rest spans) (out '())) (if (null? rest) out (loop (cdr rest) (let ins ((xs out)) (cond ((null? xs) (list (car rest))) ((> (span-scalar-start (car rest)) (span-scalar-start (car xs))) (cons (car rest) xs)) (else (cons (car xs) (ins (cdr xs)))))))))) (define (rename-file-check staged-d rel old-id new-id owner? rev) (cond ((not (eq? (descriptor-id-status staged-d old-id) 'absent)) (%txn-refuse 'postcheck-failed (format "~s still declared in staged ~a" old-id rel) current-revision: rev)) ((and owner? (not (eq? (descriptor-id-status staged-d new-id) 'unique))) (%txn-refuse 'postcheck-failed (format "~s is not uniquely declared in the staged owner ~a" new-id rel) current-revision: rev)) (else #f))) (define (folio-move-node! . args) (error "folio-move-node!: landing in the next F3b slice"))))