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:398 — tgt.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:440 — tgt.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-242 — InsertWikiLinksBeforeHeading 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):
- 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.
- 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.
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.
Description
Classification: CLI defect.
repair applyis documented as a safe recovery mechanism you can re-run after a partial run..claude/skills/rootline/ref-advanced.md:161:It is not declarative and it skips nothing. Every proposal applier writes
p.To/p.Valueintothe record's frontmatter unconditionally, without ever reading the value that is on disk:
internal/fix/repair.go:398—tgt.record.Frontmatter[p.Field] = p.To(correct_value,migrate_value).p.Fromis used only to build the human-readable message at:402and:422; it is never comparedagainst the current value.
internal/fix/repair.go:440—tgt.record.Frontmatter[p.Field] = value(add_field, and the otherproposal types routed through
applyRepairAddField). There is no "only if absent" guard.internal/fix/fix.go:233-242—InsertWikiLinksBeforeHeadingsplices the link block in with nopresence check, so a
migrate_valueproposal carryingwiki_linksadds them again on every run.Three consequences, all silent (
complete: true, exit0):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.from.correct_valuecarriesfromprecisely so the correction can be verified; it is discarded.migrate_valueduplicates its wiki-links on every run, so the body grows one copy per runand never converges.
repair applyrewrites 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 --allshares the same applier shape(
internal/fix/fix.go:380,:388-392) but recomputes its proposals from live validation on everyinvocation, so it is self-correcting. A
--reportfile is a frozen snapshot, which is what makesreplay destructive.
Steps to Reproduce
Requires
rootlineonPATH. Every fixture below reaches real validation: the.stemisversion 2 with a
root: trueboundary and an objectscope:, and a deliberately-broken controlrecord proves it (
validate --allreportsstem_health: [],notices: []and the control error).Case 1 — re-run clobbers a human's fix and duplicates wiki-links
Fixture reaches validation (control error present, stem parsed as v2):
First run:
A human then fills the empty required field, as
value_source: emptyinvites:$ sed -i '' 's/^owner: .*/owner: Alice/' a.mdSecond run — the documented recovery step:
owner: Aliceis gone. The wiki-link is duplicated.changed[]is byte-identical to run 1, nothingmoved to
skipped[],completeistrue, and the exit code is0.Case 2 —
fromis never consultedThe document held
owner: zed, not thefromvaluealice. It was overwritten anyway, and themessage still claims
"alice"->"bob".Expected Behavior
Per
ref-advanced.md:161and the same contract inCLAUDE.md("Recover from a partial run by re-running the report; it is declarative and skips what is already
correct"):
correct_value/migrate_valueapply only when the current value equalsfrom; otherwise theproposal lands in
skipped[](already correct) orrejected[](value has moved on).add_fieldapplies only when the field is absent.migrate_valuedoes not re-insert a wiki-link the body already contains.changed: [],skipped[]populated, and writes nothing.Actual Behavior
Every proposal is applied unconditionally on every run.
skipped[]staysnull,changed[]repeats verbatim, the file is rewritten each time, human edits between runs are destroyed, and
migrate_valuewiki-links accumulate one copy per run. Exit status is0andcompleteistrue,so no signal distinguishes a real repair from a destructive replay.
Environment
rootline version 9.12.1(equal tomasterat7d95e35)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 forset_sectionproposals; this report is about repeated real runs of an accepted proposal.