Found on 2026-09-08 while deprecating PROB-105 after #472 merged. Reproduced on a clean workspace with the shipped v0.36.0 homebrew binary.
Corrected 2026-09-08. The first version of this issue guessed a stale-LanceDB read (the PROB-074 family). That was wrong, and it understated the damage. The real cause is a design collision, and the loss is permanent, not a temporary disagreement between store and file. Both corrections are below.
What happens
forgeplan deprecate <id> --reason "..." echoes the reason back, forgeplan get <id> shows a ## Deprecation section containing it — and the markdown file never receives that section. The status change reaches the file; the reason does not.
.forgeplan/lance/ is gitignored. The file is what gets committed.
Reproducer
mkdir /tmp/repro && cd /tmp/repro
git init -q . && git commit -q --allow-empty -m init
forgeplan init -y
forgeplan new problem "Repro"
forgeplan update PROB-001 --body "## Signal
Body with real content.
## Impact
It matters.
## Related Artifacts
none"
forgeplan activate PROB-001
forgeplan deprecate PROB-001 --reason "REPRO-MARKER-12345"
grep -c 'REPRO-MARKER-12345' .forgeplan/problems/*.md # 0 <-- the file
forgeplan get PROB-001 | grep -c 'REPRO-MARKER-12345' # 1 <-- the store
grep -h '^status:' .forgeplan/problems/*.md # deprecated <-- status DID project
Observed exactly:
Deprecated PROB-001: REPRO-MARKER-12345
=== status in file: status: deprecated
=== reason in FILE: 0
=== reason in STORE: 1
Root cause — files-first projection discards the body it is handed
Not a stale read. projection::render_projection (crates/forgeplan-core/src/projection/mod.rs:137) calls render_projection_inner with force_body = false, and that function at lines 291-310 throws the passed body away whenever the file exists with a non-empty body:
} else if filepath.exists() {
match tokio::fs::read_to_string(&filepath).await {
Ok(file_content) => match frontmatter::parse_frontmatter(&file_content) {
Ok((fm, file_body)) => {
let preserved = filter_preserved(&fm);
if !file_body.trim().is_empty() {
(file_body.to_string(), preserved) // <-- passed `body` discarded
This is documented and deliberate — its own doc comment at :131-135 says "Files-first (RFC-004): If the file already exists and the user has edited the body … the file body is preserved. Only frontmatter (status, links, metadata) is updated from LanceDB." It is load-bearing: it is what stops link / tag / activate from clobbering a user's edits with a stale DB body.
The status survives because status lives in frontmatter, which is regenerated. That is why a stale-read theory does not fit: status and body come from the same record returned by the same get_record call.
So this is a collision between two correct behaviours — files-first body preservation, and lifecycle commands that legitimately need to append to the body.
The three lifecycle functions that append a section all push it to the store only:
| line |
function |
section |
lifecycle/mod.rs:500 |
deprecate |
## Deprecation |
lifecycle/mod.rs:562 |
renew |
## Renewal |
lifecycle/mod.rs:659 |
reopen |
## Reopened |
The loss is permanent, not a temporary disagreement
read_file_body_if_newer (projection/mod.rs:425-449) compares content, not mtime:
if !file_trimmed.is_empty() && file_trimmed != db_trimmed { Some(file_body.to_string()) }
After a deprecate, file body and DB body differ. Every lifecycle command opens with sync_file_to_store (deprecate.rs:19, renew.rs:19, reopen.rs:19, MCP sync_before_mutation), so the next mutation on that artifact writes the section-less file body over the DB — and the reason disappears from LanceDB too. forgeplan get stops showing it. The window where the two surfaces disagree is temporary; the data is gone for good.
reopen loses its section the same way
Verified separately. forgeplan reopen PROB-001 --reason "REOPEN-MARKER-67890" leaves the deprecated artifact with no ## Reopened section at all:
--- PROB-001-....md status: deprecated '## Reopened': 0 marker: 0
--- PROBLEM-001-...-reopened.md status: draft '## Reopened': 0 marker: 1
The marker survives only inside the new artifact's body. The record of why the old one was retired is lost. (The new artifact's file does not exist yet when it is rendered, so it takes the else branch and keeps its body — that is why it looks fine.)
renew shares the code shape and is not verified — exercising it needs an expired valid_until.
Why update --body works
commands/update.rs:143 goes through projection::update_body_with_projection, which at projection/mod.rs:1108-1118 writes the file first, then the DB, using render_projection_with_body (:231) — the identical renderer with force_body = true (:256). That single boolean is the whole difference between the working command and the broken ones.
Fix
Three CLI call sites can switch to the existing render_projection_with_body:
| Call site |
Section |
crates/forgeplan-cli/src/commands/deprecate.rs:38 |
## Deprecation |
crates/forgeplan-cli/src/commands/renew.rs:27 |
## Renewal |
crates/forgeplan-cli/src/commands/reopen.rs:43 |
## Reopened |
Forcing is safe at exactly these sites because each is preceded by sync_file_to_store, so at mutation time the DB body is the file body plus the appended section.
Do not flip the default of render_projection. Files-first is load-bearing for link / tag / activate, which pass a possibly-stale DB body.
The MCP path needs new code, not a flag: server.rs:4550 → render_after_mutation (projection/mod.rs:387) → render_projection_record (:170) has no force_body parameter at all and carries the same preservation block at :186-204.
Also noted, out of scope here: coverage/mod.rs:241 mutates the body through update_body and never renders at all, so ## Affected Files never reaches the file either.
What a test must assert
Existing tests (lifecycle/mod.rs:748, :801, :910) assert record.body.contains("## Deprecation") through the store — the half of the system that works. grep -rn "Deprecation|Reopened|Renewal" crates/*/tests/ returns zero hits: no test anywhere opens the .md. That is what let this ship.
A test must read the file off disk:
let md = std::fs::read_to_string(tmp.path().join(".forgeplan/notes/NOTE-001-....md")).unwrap();
assert!(md.contains("## Deprecation"));
assert!(md.contains("Reason: replaced by new approach"));
Plus a second lifecycle command afterwards, re-asserting both surfaces — that is what catches the permanent-loss half.
An invariant test in the spirit of crates/forgeplan-core/tests/adr_003_invariant.rs — forbidding "body mutation through lifecycle::* paired with a non-forcing render" — is what keeps the class from returning.
Recovery for anyone already hit
update projects correctly, so re-pushing the full body restores it:
# read the file, strip frontmatter, append the section by hand, then:
forgeplan update <id> --body @/tmp/body.md
Verified on PROB-105 — the section reached the file (009de50).
Refs: prob-105, #472, ADR-003, RFC-004
Found on 2026-09-08 while deprecating PROB-105 after #472 merged. Reproduced on a clean workspace with the shipped v0.36.0 homebrew binary.
What happens
forgeplan deprecate <id> --reason "..."echoes the reason back,forgeplan get <id>shows a## Deprecationsection containing it — and the markdown file never receives that section. The status change reaches the file; the reason does not..forgeplan/lance/is gitignored. The file is what gets committed.Reproducer
Observed exactly:
Root cause — files-first projection discards the body it is handed
Not a stale read.
projection::render_projection(crates/forgeplan-core/src/projection/mod.rs:137) callsrender_projection_innerwithforce_body = false, and that function at lines 291-310 throws the passed body away whenever the file exists with a non-empty body:This is documented and deliberate — its own doc comment at
:131-135says "Files-first (RFC-004): If the file already exists and the user has edited the body … the file body is preserved. Only frontmatter (status, links, metadata) is updated from LanceDB." It is load-bearing: it is what stopslink/tag/activatefrom clobbering a user's edits with a stale DB body.The status survives because status lives in frontmatter, which is regenerated. That is why a stale-read theory does not fit: status and body come from the same
recordreturned by the sameget_recordcall.So this is a collision between two correct behaviours — files-first body preservation, and lifecycle commands that legitimately need to append to the body.
The three lifecycle functions that append a section all push it to the store only:
lifecycle/mod.rs:500deprecate## Deprecationlifecycle/mod.rs:562renew## Renewallifecycle/mod.rs:659reopen## ReopenedThe loss is permanent, not a temporary disagreement
read_file_body_if_newer(projection/mod.rs:425-449) compares content, not mtime:After a deprecate, file body and DB body differ. Every lifecycle command opens with
sync_file_to_store(deprecate.rs:19,renew.rs:19,reopen.rs:19, MCPsync_before_mutation), so the next mutation on that artifact writes the section-less file body over the DB — and the reason disappears from LanceDB too.forgeplan getstops showing it. The window where the two surfaces disagree is temporary; the data is gone for good.reopenloses its section the same wayVerified separately.
forgeplan reopen PROB-001 --reason "REOPEN-MARKER-67890"leaves the deprecated artifact with no## Reopenedsection at all:The marker survives only inside the new artifact's body. The record of why the old one was retired is lost. (The new artifact's file does not exist yet when it is rendered, so it takes the
elsebranch and keeps its body — that is why it looks fine.)renewshares the code shape and is not verified — exercising it needs an expiredvalid_until.Why
update --bodyworkscommands/update.rs:143goes throughprojection::update_body_with_projection, which atprojection/mod.rs:1108-1118writes the file first, then the DB, usingrender_projection_with_body(:231) — the identical renderer withforce_body = true(:256). That single boolean is the whole difference between the working command and the broken ones.Fix
Three CLI call sites can switch to the existing
render_projection_with_body:crates/forgeplan-cli/src/commands/deprecate.rs:38## Deprecationcrates/forgeplan-cli/src/commands/renew.rs:27## Renewalcrates/forgeplan-cli/src/commands/reopen.rs:43## ReopenedForcing is safe at exactly these sites because each is preceded by
sync_file_to_store, so at mutation time the DB body is the file body plus the appended section.Do not flip the default of
render_projection. Files-first is load-bearing forlink/tag/activate, which pass a possibly-stale DB body.The MCP path needs new code, not a flag:
server.rs:4550→render_after_mutation(projection/mod.rs:387) →render_projection_record(:170) has noforce_bodyparameter at all and carries the same preservation block at:186-204.Also noted, out of scope here:
coverage/mod.rs:241mutates the body throughupdate_bodyand never renders at all, so## Affected Filesnever reaches the file either.What a test must assert
Existing tests (
lifecycle/mod.rs:748,:801,:910) assertrecord.body.contains("## Deprecation")through the store — the half of the system that works.grep -rn "Deprecation|Reopened|Renewal" crates/*/tests/returns zero hits: no test anywhere opens the.md. That is what let this ship.A test must read the file off disk:
Plus a second lifecycle command afterwards, re-asserting both surfaces — that is what catches the permanent-loss half.
An invariant test in the spirit of
crates/forgeplan-core/tests/adr_003_invariant.rs— forbidding "body mutation throughlifecycle::*paired with a non-forcing render" — is what keeps the class from returning.Recovery for anyone already hit
updateprojects correctly, so re-pushing the full body restores it:Verified on PROB-105 — the section reached the file (
009de50).Refs: prob-105, #472, ADR-003, RFC-004