Repository: open-gsd/gsd-core (2026-07-13T04:48:32Z)

Issue:
`syncStateFrontmatter` drops unknown STATE.md frontmatter keys on every mutating verb
> *This was generated by AI during triage.*

Split from #2191 (root-cause umbrella 7a).

### GSD Version
1.7.0-rc.5 (verified against `next` @246f566ee)

### What happened
Every mutating STATE verb rebuilds STATE.md frontmatter from a fixed schema (`syncStateFrontmatter` → `buildStateFrontmatter(body)` → `reconstructFrontmatter`) and preserves only an enumerated set of known keys, so any custom/unknown frontmatter key present in the file is silently dropped on every mutating verb.

### Expected behavior
Frontmatter sync carries forward any existing key it does not own; schema-owned keys still win where the schema derives a value.

### Steps to reproduce
1. Add a custom frontmatter key to STATE.md.
2. Run any mutating verb (`begin-phase`, `complete-phase`, `advance-plan`) → the custom key is gone.

---
## Agent Brief

**Category:** bug
**Summary:** `syncStateFrontmatter` rebuilds STATE.md frontmatter from a fixed schema and silently drops unknown/custom frontmatter keys on every mutating verb.

**Current behavior:** the sync derives frontmatter from `buildStateFrontmatter(body)` (which takes only body+cwd, so it cannot carry existing keys), preserves only an enumerated key set, and reconstructs — dropping any non-enumerated key.

**Desired behavior:** frontmatter sync merges any existing key it does not own onto the derived object before reconstruction; schema-owned keys still win where derived.

**Key interfaces:** the frontmatter-sync function (reads existing FM, derives schema FM, reconstructs) — merge non-owned existing keys onto the derived object before reconstruction, rather than emitting only derived+enumerated keys.

**Acceptance criteria:**
- [ ] A STATE.md with a custom frontmatter key retains it after `begin-phase`, `complete-phase`, `advance-plan`.
- [ ] Schema-owned keys unchanged.
- [ ] Failing-first regression: custom key dropped before, retained after.

**Out of scope:** the milestone-`Status` ownership question (parent 7b — a design call, tracked separately); progress recompute semantics.



Discussion:
> *This was generated by AI during triage.*

## Triage: CONFIRMED — `confirmed-bug` applied (stays `ready-for-agent`)

Independently verified against `next` HEAD `96b632d4e`; matches the Agent Brief in the body.

**Mechanism.** `syncStateFrontmatter` (`src/state.cts:1553-1629`) reads `existingFm` but rebuilds output solely from `derivedFm = buildStateFrontmatter(...)` (`src/state.cts:1514-1536`), which seeds a fresh object from a fixed schema. The preserve-fallback block special-cases only ~8 schema-owned keys (`status`, `milestone_name`/`milestone`, `stopped_at`, `paused_at`, `current_phase`, `current_phase_name`, `current_plan`, `progress`); `reconstructFrontmatter(derivedFm)` (`src/frontmatter.cts:177-241`) then serializes only `derivedFm`, with no merge of unknown keys from `existingFm`. Every mutating verb routes through `writeStateMd`/`readModifyWriteStateMd` (`src/state.cts:1849+`), so any custom frontmatter key (e.g. `owner: alice`) survives a read but is silently dropped on the next write.

This is the residual gap left by #905 (which added the 8-key preserve block for schema scalars only, not arbitrary keys); not a duplicate. Fix direction per the body brief: merge non-owned existing keys onto `derivedFm` before reconstruction, with schema-owned keys still winning.




Patch:
diff --git a/.changeset/agile-pandas-dance.md b/.changeset/agile-pandas-dance.md
new file mode 100644
index 0000000000..5041d24ea5
--- /dev/null
+++ b/.changeset/agile-pandas-dance.md
@@ -0,0 +1,5 @@
+---
+type: Fixed
+pr: 2233
+---
+**Custom STATE.md frontmatter keys are no longer dropped on every mutating verb** — syncStateFrontmatter rebuilt the frontmatter from a fixed schema, silently dropping any custom key. It now carries forward existing keys the schema does not own. (#2202)
diff --git a/src/state.cts b/src/state.cts
index 5e59b73701..f7f3811ac1 100644
--- a/src/state.cts
+++ b/src/state.cts
@@ -1650,6 +1650,16 @@ function syncStateFrontmatter(content: string, cwd: string | undefined): string
     derivedFm['progress'] = normalizeProgressNumbers(existingFm['progress']);
   }
 
+  // #2202: carry forward any existing frontmatter key that the schema does not
+  // own, so custom/unknown keys are not silently dropped on every mutating verb.
+  // Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
+  // preserve guards above) still win.
+  for (const key of Object.keys(existingFm)) {
+    if (!(key in derivedFm) && existingFm[key] !== undefined) {
+      derivedFm[key] = existingFm[key];
+    }
+  }
+
   const yamlStr = reconstructFrontmatter(derivedFm as unknown as Frontmatter);
   return `---\n${yamlStr}\n---\n\n${body}`;
 }


Test patch:
diff --git a/tests/state.test.cjs b/tests/state.test.cjs
index eaf61a34a7..5257bb23df 100644
--- a/tests/state.test.cjs
+++ b/tests/state.test.cjs
@@ -611,6 +611,37 @@ milestone: v1.0
     assert.ok(!content.includes('status: unknown'), 'should not contain unknown status');
   });
 
+  test('#2202: preserves unknown frontmatter keys the schema does not own', () => {
+    // Regression: a mutating verb rewrites STATE.md via syncStateFrontmatter,
+    // which rebuilds frontmatter from the body + schema. Before #2202 it dropped
+    // any frontmatter key the schema does not own; custom/tooling keys must
+    // survive every write.
+    fs.writeFileSync(
+      path.join(tmpDir, '.planning', 'STATE.md'),
+      `---
+status: executing
+milestone: v1.0
+custom_tracking_id: ABC-123
+team: platform
+---
+
+# Project State
+
+**Current Phase:** 03
+**Current Plan:** 03-02
+`
+    );
+
+    // Any writeStateMd triggers syncStateFrontmatter.
+    runGsdTools('state update "Current Plan" "03-03"', tmpDir);
+
+    const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
+    assert.match(content, /custom_tracking_id: ABC-123/, 'unknown key custom_tracking_id must be preserved');
+    assert.match(content, /team: platform/, 'unknown key team must be preserved');
+    // Schema-owned keys still win / survive alongside the carried-forward keys.
+    assert.ok(content.includes('status: executing'), 'schema-owned status still preserved');
+  });
+
   test('round-trip: write then read via state json', () => {
     fs.writeFileSync(
       path.join(tmpDir, '.planning', 'STATE.md'),