Skip to content

repair apply is not declarative: re-running a report clobbers human edits and duplicates migrate_value wiki-links #178

Description

@pablontiv

Description

Classification: CLI defect.

repair apply is documented as a safe recovery mechanism you can re-run after a partial run.
.claude/skills/rootline/ref-advanced.md:161:

"Recover from a partial run by re-running the report; it is declarative and skips what is
already correct."

It is not declarative and it skips nothing. Every proposal applier writes p.To / p.Value into
the record's frontmatter unconditionally, without ever reading the value that is on disk:

  • internal/fix/repair.go:398tgt.record.Frontmatter[p.Field] = p.To (correct_value, migrate_value).
    p.From is used only to build the human-readable message at :402 and :422; it is never compared
    against the current value.
  • internal/fix/repair.go:440tgt.record.Frontmatter[p.Field] = value (add_field, and the other
    proposal types routed through applyRepairAddField). There is no "only if absent" guard.
  • internal/fix/fix.go:233-242InsertWikiLinksBeforeHeading splices the link block in with no
    presence check, so a migrate_value proposal carrying wiki_links adds them again on every run.

Three consequences, all silent (complete: true, exit 0):

  1. A re-run clobbers a value a human corrected between runs. This is the exact workflow the
    documentation recommends: the first run writes owner: "" for a missing required field
    (value_source: empty), a human fills it in, the re-run puts "" back.
  2. A stale report overwrites a value that no longer matches from. correct_value carries
    from precisely so the correction can be verified; it is discarded.
  3. migrate_value duplicates its wiki-links on every run, so the body grows one copy per run
    and never converges.

repair apply rewrites the file on every run even when the frontmatter is already the target value,
so mtime churn is unconditional too.

This bites the report-replay path specifically. fix --all shares the same applier shape
(internal/fix/fix.go:380, :388-392) but recomputes its proposals from live validation on every
invocation, so it is self-correcting. A --report file is a frozen snapshot, which is what makes
replay destructive.

Steps to Reproduce

Requires rootline on PATH. Every fixture below reaches real validation: the .stem is
version 2 with a root: true boundary and an object scope:, and a deliberately-broken control
record proves it (validate --all reports stem_health: [], notices: [] and the control error).

Case 1 — re-run clobbers a human's fix and duplicates wiki-links

rm -rf /tmp/rootline-repro-rerun && mkdir -p /tmp/rootline-repro-rerun && cd /tmp/rootline-repro-rerun

cat > .stem <<'EOF'
version: 2
root: true
scope:
  match: "*.md"
schema:
  titulo:
    type: string
    required: true
  owner:
    type: string
    required: true
  estado:
    type: string
    required: false
EOF

printf -- '---\ntitulo: Alpha\n---\n\n## Notes\n\nBody.\n' > a.md
printf -- '---\ntitulo: Beta\nestado: "Pending (blocked by E04/F01)"\nowner: x\n---\n\n## Notes\n\nBody.\n' > b.md
printf -- '---\nowner: ctrl\n---\nControl: missing titulo.\n' > control.md

cat > report.json <<'EOF'
{"version":1,"kind":"rootline/proposals","path":".","proposals":[
 {"type":"add_field","field":"owner","description":"required field \"owner\" is missing",
  "paths":["a.md"],"value_source":"empty"},
 {"type":"migrate_value","field":"estado","description":"migrate","paths":["b.md"],
  "from":"Pending (blocked by E04/F01)","to":"Pending","wiki_links":["[[blocks:E04/F01]]"]}
],"summary":{"total":2}}
EOF

Fixture reaches validation (control error present, stem parsed as v2):

$ rootline validate --all . -o json
stem_health []   notices []
a.md        false ['required field "owner" is missing']
b.md        true  []
control.md  false ['required field "titulo" is missing']

First run:

$ rootline repair apply --report report.json --fill-missing
{"version":1,"kind":"rootline/repair","root":"<fixture>","complete":true,"dry_run":false,
 "changed":["add owner=\"\" in a.md","correct estado: \"Pending (blocked by E04/F01)\"->\"Pending\" in b.md"],
 "skipped":null,"rejected":null,"errors":null}
$ echo $?
0

A human then fills the empty required field, as value_source: empty invites:

$ sed -i '' 's/^owner: .*/owner: Alice/' a.md

Second run — the documented recovery step:

$ rootline repair apply --report report.json --fill-missing
{"version":1,"kind":"rootline/repair","root":"<fixture>","complete":true,"dry_run":false,
 "changed":["add owner=\"\" in a.md","correct estado: \"Pending (blocked by E04/F01)\"->\"Pending\" in b.md"],
 "skipped":null,"rejected":null,"errors":null}
$ echo $?
0

$ cat a.md
---
titulo: Alpha
owner: ""
---

## Notes

Body.

$ cat b.md
---
titulo: Beta
estado: Pending
owner: x
---

[[blocks:E04/F01]]

[[blocks:E04/F01]]

## Notes

Body.

owner: Alice is gone. The wiki-link is duplicated. changed[] is byte-identical to run 1, nothing
moved to skipped[], complete is true, and the exit code is 0.

Case 2 — from is never consulted

rm -rf /tmp/rootline-repro-from && mkdir -p /tmp/rootline-repro-from && cd /tmp/rootline-repro-from

cat > .stem <<'EOF'
version: 2
root: true
scope:
  match: "*.md"
schema:
  titulo:
    type: string
    required: true
  owner:
    type: string
    required: false
EOF

printf -- '---\ntitulo: One\nowner: zed\n---\nBody one.\n' > one.md
printf -- '---\nowner: carol\n---\nControl: missing titulo.\n' > control.md

cat > r.json <<'EOF'
{"version":1,"kind":"rootline/proposals","path":".","proposals":[
 {"type":"correct_value","field":"owner","description":"correct owner","paths":["one.md"],
  "from":"alice","to":"bob"}],"summary":{"total":1}}
EOF

rootline repair apply --report r.json; echo "exit=$?"; cat one.md
{"version":1,"kind":"rootline/repair","root":"<fixture>","complete":true,"dry_run":false,
 "changed":["correct owner: \"alice\"->\"bob\" in one.md"],"skipped":null,"rejected":null,"errors":null}
exit=0
---
titulo: One
owner: bob
---
Body one.

The document held owner: zed, not the from value alice. It was overwritten anyway, and the
message still claims "alice"->"bob".

Expected Behavior

Per ref-advanced.md:161 and the same contract in CLAUDE.md
("Recover from a partial run by re-running the report; it is declarative and skips what is already
correct"):

  • correct_value / migrate_value apply only when the current value equals from; otherwise the
    proposal lands in skipped[] (already correct) or rejected[] (value has moved on).
  • add_field applies only when the field is absent.
  • migrate_value does not re-insert a wiki-link the body already contains.
  • A fully-replayed report reports changed: [], skipped[] populated, and writes nothing.

Actual Behavior

Every proposal is applied unconditionally on every run. skipped[] stays null, changed[]
repeats verbatim, the file is rewritten each time, human edits between runs are destroyed, and
migrate_value wiki-links accumulate one copy per run. Exit status is 0 and complete is true,
so no signal distinguishes a real repair from a destructive replay.

Environment

  • Rootline version: rootline version 9.12.1 (equal to master at 7d95e35)
  • OS: macOS (darwin/arm64)
  • Go version: n/a (installed release binary)

Notes

Not a duplicate of the closed #61 (exit codes, atomicity, report-root resolution) or #137
(file mode widening) — both left the applier bodies' write-unconditionally shape untouched. Not a
duplicate of #163, which is about a --dry-run / real-run disagreement for set_section
proposals; this report is about repeated real runs of an accepted proposal.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions