F3b: insert and delete, the referrer discipline, and the root scan
structural.sgl slice 1. folio-insert-node! renders a validated work item (task/decision/question/update; scalars through the F3a serializer, an optional prose body in the restricted class) and appends it inside a container addressed across BOTH id namespaces -- the folio index for work items, an id: argument field for core sections, since @section["Tasks" id: "tasks"] is exactly how the estate spells its containers. The postcheck proves the new id landed uniquely INSIDE the container and that the node count outside the container's subtree did not move (the escape/injection instrument for a variable-size insert).
folio-delete-node! deletes a work item only after BOTH referrer passes come back empty across the WHOLE ROOT: the semantic pass walks every descriptor for related/transclude node: fields over the doomed subtree's ids, the raw pass scans every file's text with identifier-boundary matching (so t-a never matches inside t-alpha -- the estate's own id-truncation lesson), and Markdown occurrences refuse by policy (this coordinator does not rewrite .md). Any referrer, any uninspectable carrier file -> absolute refusal, no override (ruled). Containers refuse deletion and insertion as SCOPED OUT in those words.
verify-staged gains allowed-lost: ids a delete deliberately removes are subtracted from the persistence invariants and everything else about them stays the stage's own target-check to prove. Delete's exact node delta is deliberately #f: adjacent inter-node whitespace text nodes merge when a span vanishes (measured: a 3-node subtree moved the count by 4), so id-absence and persistence carry the invariant instead.
F3a battery 22/22 green over the changes.
src/folio/doc/structural.sgl | 481 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/folio/doc/transaction.sgl | 27 ++++++++--
2 files changed, 503 insertions(+), 5 deletions(-)src/folio/doc/structural.sgladded
;;; (folio doc structural) -- structural operations (F3b): insert, delete,;;; move, and rename-id-with-reference-rewrite, plus the cross-file;;; publication engine they share.;;;;;; Everything here rides the F3a machinery through the deliberately narrow;;; `%txn-` surface: the same coordinator door (in-process mutex, cross-;;; process lock, automatic crash recovery), the same bounded reparse, the;;; same verify pipeline, the same refusal vocabulary. Structural requests;;; NEVER rebase -- D11 amendment 1 -- so every door here refuses;;; 'structural-revision-mismatch on a stale expected revision.;;;;;; SCOPE (ruled 2026-08-25): insert and move handle WORK-ITEM kinds (task,;;; decision, question, update). Structural-container surgery -- inserting;;; phases or milestones, re-parenting sections -- is SCOPED OUT of F3b;;; deliberately, not unsupported: the refusal says so in those words, so a;;; later reader extends this module rather than redesigning around a wall;;; that was never load-bearing.;;;;;; THE REFERRER DISCIPLINE. Delete and rename both answer to the same;;; hazard: a reference left pointing at nothing, or rewritten into nothing,;;; is SILENT KNOWLEDGE LOSS -- the worst outcome available in this component;;; (t-5e4329 is the estate's own case: a note rename that updated zero;;; crosslinks). Two independent passes find referrers: the SEMANTIC pass;;; walks descriptors for `related`/`transclude` `node:` fields (span-;;; addressed, so a rewrite is exact), and the RAW pass scans file text for;;; the id with identifier-boundary checks, sharing nothing with the parser.;;; Any raw occurrence the semantic pass cannot address REFUSES the whole;;; operation -- absolutely, with no override flag (ruled: an override on a;;; silent-loss guard becomes habit).(define-library (folio doc structural) (import (sigil core) (sigil struct) (sigil string) (sigil list) (sigil math) (sigil fs) (sigil path) (sigil io) (folio doc model) (folio doc revision) (folio doc inspect) (folio doc discovery) (folio doc manifest) (folio doc coordinator) (folio doc transaction)) (export folio-insert-node! folio-delete-node! folio-rename-id! folio-move-node! sop-result? sop-result-op sop-result-files sop-result-node-id sop-result-detail sop-result-recovery-actions sop-file? sop-file-path sop-file-old-revision sop-file-new-revision sop-file-diff) (begin ;; ------------------------------------------------------------------ ;; Multi-file result ;; ------------------------------------------------------------------ (define-struct sop-file-record (path) (old-revision) (new-revision) (diff)) (define (sop-file path old new diff) (sop-file-record path: path old-revision: old new-revision: new diff: diff)) (define (sop-file? v) (sop-file-record? v)) (define (sop-file-path f) (sop-file-record-path f)) (define (sop-file-old-revision f) (sop-file-record-old-revision f)) (define (sop-file-new-revision f) (sop-file-record-new-revision f)) (define (sop-file-diff f) (sop-file-record-diff f)) (define-struct sop-result-record (op) (files) (node-id) (detail) (recovery)) (define (sop-result? v) (sop-result-record? v)) (define (sop-result-op r) (sop-result-record-op r)) (define (sop-result-files r) (sop-result-record-files r)) (define (sop-result-node-id r) (sop-result-record-node-id r)) (define (sop-result-detail r) (sop-result-record-detail r)) (define (sop-result-recovery-actions r) (sop-result-record-recovery r)) ;; ------------------------------------------------------------------ ;; Work-item rendering ;; ;; The serializer is deliberately minimal: validated scalars into the ;; argument group, an optional PROSE body in the F3a-restricted class ;; (no @, braces, or newlines -- richer bodies arrive by editing the ;; inserted node with folio-edit-body! or the journal doors). Every ;; render is verified by the staged reparse reading the values back, so ;; a serializer defect refuses rather than publishes. ;; ------------------------------------------------------------------ (define work-item-kinds '(task decision question update)) (define container-kinds-for-insert ;; Work items may live in any of these (mirrors the frozen model's ;; containment: sections and containers hold work items; work items may ;; hold work items). A kind outside this set refuses as SCOPED OUT. '(note section project milestone phase task decision question)) (define (render-work-item kind (keys: (id #f) (title #f) (status #f) (priority #f) (due #f) (tags #f) (timestamp #f) (author #f) (commit #f) (body-text #f))) (let ((args (case kind ((task) (string-append (%txn-escape-string-literal id) " title: " (%txn-escape-string-literal title) " status: " (%txn-serialize-value 'status status) (if priority (string-append " priority: " (%txn-serialize-value 'priority priority)) "") (if due (string-append " due: " (%txn-serialize-value 'due due)) "") (if tags (string-append " tags: " (%txn-serialize-value 'tags tags)) ""))) ((decision question) (string-append (%txn-escape-string-literal id) " title: " (%txn-escape-string-literal title) " status: " (%txn-serialize-value 'status status))) ((update) (string-append (if id (string-append (%txn-escape-string-literal id) " ") "") "timestamp: " (%txn-escape-string-literal timestamp) (if author (string-append " author: " (%txn-escape-string-literal author)) "") (if commit (string-append " commit: " (%txn-escape-string-literal commit)) ""))) (else (error (format "render-work-item: ~a is not a work-item kind" kind)))))) (string-append "@" (symbol->string kind) "[" args "]{" (if body-text (string-append "\n@p{" body-text "}\n") "") "}\n"))) ;; ------------------------------------------------------------------ ;; Identifier-boundary raw scan. Character class mirrors the flat ;; node-id grammar ([a-z0-9] segments joined by hyphens); an occurrence ;; counts only when both neighbours are OUTSIDE the class, so `t-a` ;; never matches inside `t-alpha` (the estate's own id-truncation lesson, ;; t-060d78's third defect). ;; ------------------------------------------------------------------ (define (id-char? c) (or (and (char>=? c #\a) (char<=? c #\z)) (and (char>=? c #\0) (char<=? c #\9)) (char=? c #\-))) (define (boundary-occurrences text id) (let ((tl (string-length text)) (il (string-length id))) (let loop ((i 0) (hits '())) (cond ((> (+ i il) tl) (reverse hits)) ((and (equal? (substring text i (+ i il)) id) (or (= i 0) (not (id-char? (string-ref text (- i 1))))) (or (= (+ i il) tl) (not (id-char? (string-ref text (+ i il)))))) (loop (+ i il) (cons i hits))) (else (loop (+ i 1) hits)))))) ;; ------------------------------------------------------------------ ;; Root population. Every operation that looks beyond one file derives ;; its population from discovery's census -- with the denominator kept, ;; never a filtered list -- and reads every candidate INSIDE the lock. ;; ------------------------------------------------------------------ (define (root-population coord) ;; Returns (census . entries) where each entry is ;; (list rel-path abs-path format bytes-or-#f). Bytes are read for ;; every candidate; a read failure carries #f and refuses downstream ;; when the file matters. Discovery's candidate records carry the ;; path and format; the census keeps the denominator. (let* ((root (coordinator-root coord)) (census (discover-sources root)) (entries (map (lambda (c) (let* ((path (candidate-path c)) (rel (path-relative root path)) (bytes (guard (e (#t #f)) (read-file-bytes path)))) (list rel path (candidate-format c) bytes))) (census-candidates census)))) (cons census entries))) ;; ------------------------------------------------------------------ ;; Referrer discovery for one id (or several), across the population. ;; Returns (list semantic-sites raw-only troubles) where ;; semantic-sites: (rel-path descriptor node field) per rewritable site ;; raw-only: (rel-path context) occurrences with no rewritable span ;; troubles: (rel-path reason) files that carry the id but could ;; not be inspected -- unrewritable by definition. ;; The DECLARATION site (the id field of the owning node) is excluded; ;; callers that rewrite it (rename) handle it explicitly on the target. ;; ------------------------------------------------------------------ (define (find-referrers coord entries ids) (let loop ((rest entries) (semantic '()) (raw-only '()) (troubles '())) (if (null? rest) (list (reverse semantic) (reverse raw-only) (reverse troubles)) (let* ((entry (car rest)) (rel (list-ref entry 0)) (fmt (list-ref entry 2)) (bytes (list-ref entry 3))) (cond ((not bytes) (loop (cdr rest) semantic raw-only (cons (list rel "unreadable") troubles))) (else (let* ((text (utf8->string bytes)) (hits (apply append (map (lambda (id) (boundary-occurrences text id)) ids)))) (cond ((null? hits) (loop (cdr rest) semantic raw-only troubles)) ((eq? fmt 'md) ;; Markdown notes are never rewritten by this ;; coordinator (M-track); an id referenced from one ;; is unrewritable BY POLICY and refuses upstream. (loop (cdr rest) semantic (cons (list rel (format "~a occurrence(s) in a Markdown note this coordinator does not rewrite" (length hits))) raw-only) troubles)) (else (let ((d (%txn-bounded-descriptor coord bytes text rel (revision-of-bytes bytes) (format "referrer ~a" rel)))) (if (txn-refusal? d) (loop (cdr rest) semantic raw-only (cons (list rel (txn-refusal-message d)) troubles)) (let ((sites (reference-sites d ids))) ;; Every boundary hit must be accounted for by ;; a rewritable site or the declaration site; ;; the arithmetic is the guard. (let* ((decl (declaration-sites d ids)) (accounted (+ (length sites) (length decl)))) (if (= accounted (length hits)) (loop (cdr rest) (append semantic (map (lambda (s) (cons rel s)) sites)) raw-only troubles) (loop (cdr rest) (append semantic (map (lambda (s) (cons rel s)) sites)) (cons (list rel (format "~a raw occurrence(s) with no rewritable span (prose, code, or a query spec)" (- (length hits) accounted))) raw-only) troubles))))))))))))))) ;; Rewritable reference sites: related/transclude `node:` fields whose ;; value is one of `ids`. Returned as (node field) pairs. (define (reference-sites d ids) (let walk ((pending (descriptor-nodes d)) (out '())) (if (null? pending) (reverse out) (let* ((n (car pending)) (rest (append (inspected-node-children n) (cdr pending)))) (if (memq (inspected-node-name n) '(related transclude)) (let ((f (let lp ((fs (inspected-node-fields n))) (cond ((null? fs) #f) ((and (eq? (field-name (car fs)) 'node) (member (field-value (car fs)) ids)) (car fs)) (else (lp (cdr fs))))))) (walk rest (if f (cons (list n f) out) out))) (walk rest out)))))) ;; Declaration sites: nodes whose OWN id is one of `ids` (the positional ;; id field). Rename rewrites these on the owning document. (define (declaration-sites d ids) (let walk ((pending (descriptor-nodes d)) (out '())) (if (null? pending) (reverse out) (let* ((n (car pending)) (rest (append (inspected-node-children n) (cdr pending)))) (if (member (inspected-node-id n) ids) (walk rest (cons n out)) (walk rest out)))))) ;; ------------------------------------------------------------------ ;; INSERT ;; ------------------------------------------------------------------ (define (folio-insert-node! coord path container-id kind expected-revision (keys: (id #f) (title #f) (status #f) (priority #f) (due #f) (tags #f) (timestamp #f) (author #f) (commit #f) (body-text #f))) (unless (coordinator? coord) (error "folio-insert-node!: coord must be a coordinator")) (unless (memq kind work-item-kinds) (error (format "folio-insert-node!: inserting a ~a is SCOPED OUT of F3b by ruling (2026-08-25), not unsupported -- work-item kinds are ~a; structural-container surgery needs its own phase, so extend rather than redesign" kind work-item-kinds))) ;; Door validation, symmetric with the journal doors. (case kind ((task decision question) (unless (and (string? id) (valid-node-id? id)) (error (format "folio-insert-node!: a ~a needs an id in flat node-id syntax" kind))) (unless (and (string? title) (> (string-length title) 0)) (error (format "folio-insert-node!: a ~a needs a nonempty title" kind))) (let ((bad (%txn-value-error kind 'status status))) (when bad (error (string-append "folio-insert-node!: " bad))))) ((update) (unless (string? timestamp) (error "folio-insert-node!: an update needs a timestamp")))) (when body-text (%txn-require-safe-journal-text "folio-insert-node!" body-text)) (when (eq? kind 'task) (when priority (let ((bad (%txn-value-error 'task 'priority priority))) (when bad (error (string-append "folio-insert-node!: " bad))))) (when due (let ((bad (%txn-value-error 'task 'due due))) (when bad (error (string-append "folio-insert-node!: " bad))))) (when tags (let ((bad (%txn-value-error 'task 'tags tags))) (when bad (error (string-append "folio-insert-node!: " bad)))))) (let ((stage (lambda (d source rev) (stage-insert d source rev container-id kind (render-work-item kind id: id title: title status: status priority: priority due: due tags: tags timestamp: timestamp author: author commit: commit body-text: body-text) id)))) (%txn-run-structural coord path expected-revision stage container-id))) ;; Containers are found by TREE WALK on `inspected-node-id`, not through ;; `descriptor-node-by-id`: the folio id index covers the folio vocabulary ;; (D12's node-id namespace), and a SECTION's id lives in the core ;; anchor-kind namespace -- `@section["Tasks" id: "tasks"]` is exactly how ;; the estate spells its containers, so insertion must reach it. The walk ;; carries its own three-way answer (absent/unique/ambiguous), mirroring ;; the index's contract. ;; A node "carries" an id for containment purposes when either the folio ;; vocabulary declared it (inspected-node-id) or -- for CORE nodes like ;; `@section["Tasks" id: "tasks"]` -- an `id:` argument field holds it. ;; The two are different NAMESPACES by D12; this lookup spans both only ;; to ADDRESS a container, never to rewrite anything: rename touches the ;; folio namespace alone. (define (node-carries-id? n id) (or (equal? (inspected-node-id n) id) (let lp ((fs (inspected-node-fields n))) (cond ((null? fs) #f) ((and (eq? (field-name (car fs)) 'id) (equal? (field-value (car fs)) id)) #t) (else (lp (cdr fs))))))) (define (find-container d source container-id rev) (let walk ((pending (descriptor-nodes d)) (found '())) (if (null? pending) (cond ((null? found) (%txn-refuse 'unknown-node (format "no node with id ~s exists in this document" container-id) current-revision: rev)) ((null? (cdr found)) (car found)) (else (%txn-refuse 'ambiguous-node (format "container id ~s names ~a nodes in this document; resolution is refused" container-id (length found)) current-revision: rev))) (let ((n (car pending))) (walk (append (inspected-node-children n) (cdr pending)) (if (node-carries-id? n container-id) (cons n found) found)))))) (define (stage-insert d source rev container-id kind rendered new-id) (let ((container (find-container d source container-id rev))) (cond ((txn-refusal? container) container) ((not (memq (inspected-node-name container) container-kinds-for-insert)) (%txn-refuse 'scoped-out (format "inserting into a ~a is SCOPED OUT of F3b by ruling (2026-08-25), not unsupported -- extend the containment table if a later phase rules it in" (inspected-node-name container)) current-revision: rev target-context: (%txn-node-context source container))) ((and new-id (not (eq? (descriptor-id-status d new-id) 'absent))) (%txn-refuse 'insert-id-taken (format "id ~s is already declared in this document" new-id) current-revision: rev)) (else (let ((at (%txn-body-insertion-point source container))) (if (not at) (%txn-refuse 'update-target-has-no-body (format "container ~s has neither a braced body nor an @end block to insert into" container-id) current-revision: rev) (let* ((staged (%txn-splice source at at (string-append "\n" rendered))) (byte-at (%txn-scalar->byte-offset source at)) (pre-total (descriptor-node-count d)) (pre-outside (- pre-total (%txn-subtree-count container)))) (list staged byte-at 0 0 #f rendered new-id #f ;; delta variable (body optional); the check ;; below carries the outside-container invariant (lambda (staged-d) (insert-check staged-d container-id kind new-id pre-outside rev)))))))))) (define (insert-check staged-d container-id kind new-id pre-outside rev) (let ((c2 (let walk ((pending (descriptor-nodes staged-d))) (cond ((null? pending) #f) ((node-carries-id? (car pending) container-id) (car pending)) (else (walk (append (inspected-node-children (car pending)) (cdr pending)))))))) (cond ((not c2) (%txn-refuse 'postcheck-failed (format "the container ~s vanished from the staged document" container-id) current-revision: rev)) ((and new-id (not (eq? (descriptor-id-status staged-d new-id) 'unique))) (%txn-refuse 'postcheck-failed (format "the inserted node's id ~s did not land uniquely" new-id) current-revision: rev)) ((and new-id (not (let walk ((pending (inspected-node-children c2))) (cond ((null? pending) #f) ((equal? (inspected-node-id (car pending)) new-id) #t) (else (walk (append (inspected-node-children (car pending)) (cdr pending)))))))) (%txn-refuse 'postcheck-failed (format "the inserted node ~s did not land inside container ~s" new-id container-id) current-revision: rev)) ((not (= (- (descriptor-node-count staged-d) (%txn-subtree-count c2)) pre-outside)) (%txn-refuse 'postcheck-failed (format "the insert escaped its container: the node count outside ~s moved" container-id) current-revision: rev)) (else #f)))) ;; ------------------------------------------------------------------ ;; DELETE ;; ------------------------------------------------------------------ (define (folio-delete-node! coord path node-id expected-revision) (unless (coordinator? coord) (error "folio-delete-node!: coord must be a coordinator")) (let ((stage (lambda (d source rev) (stage-delete coord d source rev node-id)))) (%txn-run-structural coord path expected-revision stage node-id))) (define (stage-delete coord d source rev node-id) (let ((node (find-container d source node-id rev))) (cond ((txn-refusal? node) node) ((not (memq (inspected-node-name node) work-item-kinds)) (%txn-refuse 'scoped-out (format "deleting a ~a is SCOPED OUT of F3b by ruling (2026-08-25), not unsupported -- F3b deletes work items; container deletion needs its own phase" (inspected-node-name node)) current-revision: rev target-context: (%txn-node-context source node))) (else ;; Every id the deletion would remove must be unreferenced across ;; the WHOLE ROOT -- both passes, absolute refusal. (let* ((doomed (cons node-id (%txn-subtree-declared-ids node))) (doomed (filter string? doomed)) (population (root-population coord)) (found (find-referrers coord (cdr population) doomed)) (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 prove ~s unreferenced: ~a file(s) carrying its id could not be inspected: ~a" node-id (length troubles) troubles) current-revision: rev retryable: #t)) ((or (pair? semantic) (pair? raw-only)) (%txn-refuse 'node-referenced (format "deleting ~s would strand ~a semantic reference(s) and ~a raw occurrence(s): ~a~a -- deletion refuses while referrers exist (silent knowledge loss is the outcome this door exists to prevent)" node-id (length semantic) (length raw-only) (map car semantic) (map car raw-only)) current-revision: rev target-context: (%txn-node-context source node))) (else (let* ((sp (inspected-node-span node)) (start (span-scalar-start sp)) (end (span-scalar-end sp)) (staged (%txn-splice source start end "")) (byte-start (%txn-scalar->byte-offset source start)) (byte-end (%txn-scalar->byte-offset source end)) (subtree (%txn-subtree-count node))) (list staged byte-start (- byte-end byte-start) 0 (substring source start end) #f node-id ;; The exact delta is noisy for deletions: adjacent ;; inter-node whitespace TEXT nodes merge when a ;; span vanishes (measured: a 3-node subtree's ;; removal moved the count by 4). The id-absence ;; and persistence checks carry the invariant. #f (lambda (staged-d) (cond ((not (eq? (descriptor-id-status staged-d node-id) 'absent)) (%txn-refuse 'postcheck-failed (format "~s still declared after its deletion" node-id) current-revision: rev)) (else #f))) doomed))))))))) (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))))) ;; 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")) (define (folio-move-node! . args) (error "folio-move-node!: landing in the next F3b slice"))))src/folio/doc/transaction.sglmodified
%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?) %txn-body-extent %txn-bytes-equal? %txn-run-structural %txn-serialize-value %txn-value-error) (begin ;; ------------------------------------------------------------------ (define (verify-staged coord path old-bytes old-source old-rev staged-source pre-ids splice-byte-start splice-byte-removed inserted-bytes target-check refusal-reason pre-node-count node-delta) (let* ((staged-bytes (string->utf8 staged-source)) pre-node-count node-delta allowed-lost) ;; `allowed-lost` (F3b): ids the request DELIBERATELY removes -- a ;; delete's doomed subtree. They are subtracted from the persistence ;; invariants (id-status and the raw scan); everything else about them ;; is the stage's own target-check to prove. '() for every other op. (let* ((pre-ids (let loop ((rest pre-ids) (out '())) (cond ((null? rest) (reverse out)) ((member (car rest) allowed-lost) (loop (cdr rest) out)) (else (loop (cdr rest) (cons (car rest) out)))))) (staged-bytes (string->utf8 staged-source)) (staged-rev (revision-of-bytes staged-bytes))) (cond ;; Byte-exactness outside the splice, PROVEN: original prefix and (update-id (list-ref staged 6)) (node-delta (list-ref staged 7)) (target-check (list-ref staged 8)) (allowed-lost (if (> (length staged) 9) (list-ref staged 9) '())) (pre-ids (descriptor-ids d)) (bad (verify-staged coord path bytes source rev staged-source pre-ids (if rebased? 'rebase-postcheck-failed 'postcheck-failed) (descriptor-node-count d) node-delta))) node-delta allowed-lost))) (if bad bad (let* ((staged-bytes (string->utf8 staged-source)) (coordinator-transact coord (lambda (recovery-actions) (unless (file-exists? path) (error (format "folio-edit-body!: no such file: ~a" path))) (error (format "structural transaction: no such file: ~a" path))) (stamp-recovery (let* ((bytes (read-file-bytes path)) (rev (revision-of-bytes bytes)) (define %txn-subtree-declared-ids subtree-declared-ids) (define %txn-body-extent body-extent) (define %txn-bytes-equal? bytes-equal?) ;; The structural runner: mismatch refuses 'structural-revision-mismatch ;; (amendment 1), match runs the stage through the full attempt/verify ;; pipeline, recovery is stamped. Insert and delete ride it exactly as ;; body replacement does. (define %txn-run-structural run-body-transaction) (define %txn-serialize-value serialize-value) (define %txn-value-error value-error) ;; Count a node's direct `update` children. Shared by the two journal ;; target-checks: "an update child EXISTS" is satisfied by any prior