Skip to content

lifecycle: deprecate/reopen write the reason to LanceDB only — it never reaches the markdown file, and lance/ is gitignored #478

Description

@explosivebit

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:4550render_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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions