#4906·gsd-core

Epic: planning documents are read and written through one parse → mutate → serialize seam

Author: trek-eCreated Sep 21, 2026Updated Sep 21, 2026
Labelsarea: coretype: choreepic

An approved epic does not approve its children — each child is its own issue before code.

Epic: planning documents are read and written through one parse → mutate → serialize seam

The deliverable is the seam, not the individual fixes. The issues absorbed below are evidence that one piece of behavior has no single owner. They are the symptom list, not the work list. Repairing them where they were reported — N fixes for N issues — leaves the missing owner still missing, and the next occurrence is already being written somewhere else in the tree. An implementation that does that has not closed this epic, even with every symptom gone and CI green.

Closing this epic means three things land together: one PlanningDoc read/mutate/write seam, in which a structural write replaces a parsed node and never a regex span, and an unrecognised grammar is a typed parse error and never an empty result; the duplicate implementations are deleted rather than kept in sync, because two copies that agree today are the same defect as two that disagree; and enforcement is by construction — the write boundary accepts a PlanningDoc mutation, not a string, so a reintroduced content.replace(/…/) against a planning artifact does not typecheck. The first two without the third un-consolidate quietly as soon as someone who has not read this issue touches the area.

A defect that cannot be expressed through the seam is a finding about the seam's design and belongs in this issue as a comment — one special case is a grammar the parser must accept but the writer must never emit.

This is the shape that worked in #1372 (markdown sectionizer), #2143 (table, bounded mutation, fail-loud) and #2121 (phase identifier): each closed its bug class with a seam, a migration and an anti-divergence guard, none of them by fixing the reported symptoms. Splitting delivery across several PRs is expected and welcome; splitting it so that the seam never lands is not.

Twelve open confirmed-bug issues are one defect: GSD's durable state lives in prose markdown, and every verb brings its own regex to it. The seams to do this correctly already exist and are not reached.

Why the existing seams did not stop this

#1372 shipped markdown-sectionizer, #2143 shipped markdown-table with bounded mutation and a fail-loud reader. Both are real and both are good. What neither covers is the composition: a planning document is frontmatter + sections + tables + bold-label fields + checklists, and the verbs that mutate it reach past all three seams to a String.replace. The result is a repo that owns a strict escaping reader and ships an unescaped prose writer for the same table.

Four arms, one remedy

(a) The writer can emit what the reader is required to reject.

Issue Writer Reader
#4736 quick.md Step 7c interpolates raw ${DESCRIPTION} into a pipe row, no escape instruction anywhere in Step 7b/7c parseMarkdownTable, made deliberately fail-loud by #2133 / ADR-2143 §7; escapeCell exists at markdown-table.cjs:643 and is applied per column at :749-750
#4793 discuss-phase emits the bullets — the offending text arrived in the repo under docs(01.1): capture phase context parseDecisions rejects a second colon in a plain-prose bold lead-in; a single miss forces outcome: "could-not-parse", hard-blocking check.decision-coverage-plan

#4736 is the sharpest statement of the problem: a task description containing a Jinja filter (dict2items|selectattr|items2dict) permanently ragged the table, and quick-tasks-migrate cannot help — the next /gsd-quick re-corrupts it. A project that migrates is not out of the state.

(b) A structural write replaces a line span and destroys what shares the line.

Issue Pattern Loss
#4852 /(\*\*Plans:\*\*\s*)[^\n]+/ in src/phase.cts — one capture group, rest of line matched and uncaptured 174 of 205 characters on a real close: a paragraph documenting a design decision. No warning, no gate, payload reports success
#4862 state.update "Last Activity" deletes last_activity_desc and state_head, while its own payload lists "Last Activity Description" under preserved

#4852 is the same defect as #2853 and #3584 — both already fixed, both in src/roadmap.cts. The sibling writer for the same line does it correctly with three groups and re-emits $3, and its comment states the rule the other site violates: "the verb owns the count token ONLY — it must not destroy hand-written prose a human placed on the line."

(c) Unrecognised grammar returns empty instead of raising.

Issue Input Result
#4899 a ROADMAP with the checklist half and no ### Phase N: detail headings — a shape templates/roadmap.md itself emits at :22-25 phases: [] and phase_count: 0, alongside missing_phase_details with 26 tokens, built from checklistOccurrences at roadmap.cjs:651-660 and then discarded. The same tree enumerates 20 phases through init.progress
#4900 ## 11. Locked decisions decisions: [], landing in unmapped_headers with the parse reporting success. Two independent causes: the leading section number is not stripped, and locked decisions is not a synonym. plan-phase/steps/adr-ingest-express-path.md cannot tell this from a doc that genuinely records nothing
#4837 list items, fences, lowercase labels, inline **mentions** after a **Requirements** label folded into the field value — one shape silently marks another phase's requirement complete

(d) The pattern is duplicated, so a fix lands at one copy.

  • #4865 — #4478's anchor fix landed in roadmap-parser.cjs; four call sites keep their own unanchored gi copy (init.cjs:2229, :2401, :3044, milestone.cjs:646). init.cjs:116, in the same file as three of them, is anchored correctly. The codebase disagrees with itself inside one file.
  • #4661/gsd:undo interpolates an unvalidated phase/plan id into an unanchored ERE over git log --oneline; four over-selection routes, and the obvious anchor fix silently under-selects fixups into a partial revert.
  • #4605, #4606pr-branch's STRUCTURAL_RE swallows nested <milestone>-phases/ as structural; #4447's fifth arm still breaks the cherry-pick chain.
  • #4499frontmatter.set, a set-one-field verb, reformats block sequences and the closing --- in the surrounding frontmatter. The YAML parses identically, so nothing breaks; a one-scalar change produces a multi-hunk diff on a tracked artifact.

The seam this epic delivers

  1. One PlanningDoc type — parse a planning artifact once into frontmatter + section tree + typed fields (bold-label lines, tables, checklists), mutate nodes, serialize. The three existing seams (markdown-sectionizer, markdown-table, a real frontmatter reader) are the layers underneath it, not alternatives to it.
  2. A structural write replaces a node. A field write owns its token and re-emits everything else on the line, by construction rather than by a third capture group each author has to remember. Serialization is byte-stable for untouched regions, which closes #4499 for free.
  3. One writer per artifact, shared with the reader. A value that cannot be represented is escaped by the writer or refused by it — never emitted for the reader to reject. Prose-interpolated table rows in workflow markdown are replaced by a gsd_run verb that appends through the seam.
  4. Unrecognised grammar is a typed parse error. phases: [] and decisions: [] must mean "none", and a shape the parser does not understand must surface as could-not-parse with the offending span — the Evidence distinction #4631 draws for gates, applied to documents.

The ratchet

The write boundary takes a PlanningDoc mutation, not a string. A raw readFileSync.replace()writeFileSync against a .planning/ artifact does not typecheck, so the class cannot be reintroduced by someone who has not read this issue. scripts/lint-planning-artifact-writer-drift.cjs already exists and is the drain point; extend it and pair with an allowlist drain (#4446 pattern) for what exists today. Every parser ships a positive control for each grammar it claims to accept, so #4837's four continuation shapes and #4900's two heading forms cannot regress silently.

Absorbed issues — evidence of the missing seam, NOT a work list

Issue Defect Arm
#4736 quick.md writes unescaped prose into the Quick Tasks table; fail-loud reader then rejects it permanently writer/reader
#4793 parseDecisions rejects a second colon in plain prose — text discuss-phase itself emits writer/reader
#4852 phase.complete replaces the **Plans:** line to end-of-line, deleting hand-written prose (3rd occurrence; #2853, #3584 fixed the sibling) span write
#4862 state.update "Last Activity" deletes last_activity_desc and state_head, reports them preserved span write
#4899 roadmap.analyze returns phases: [] for a checklist-only ROADMAP while reporting 26 missing details empty-not-error
#4900 adr-parser drops decisions under a numbered or "Locked decisions" heading; parse reports success empty-not-error
#4837 extractPhaseFieldMultiline folds list items, fences, lowercase labels and inline **mentions** into the field empty-not-error
#4865 four unanchored copies of the phase-heading pattern survive #4478's fix duplication
#4661 /gsd:undo interpolates an unvalidated id into an unanchored ERE over git log duplication
#4605 pr-branch STRUCTURAL_RE's unanchored milestones/ prefix swallows nested <milestone>-phases/ duplication
#4606 #4447's 5th arm still breaks the cherry-pick chain on third-bucket paths duplication
#4499 frontmatter.set reformats block sequences in surrounding frontmatter span write

Done when

  • No verb mutates a .planning/ artifact with a bespoke String.replace; all route through the PlanningDoc seam
  • A field write provably preserves everything else on its line, tested at the three sites #2853 / #3584 / #4852 hit
  • Every planning-document writer shares its escaping with the reader that consumes it; no writer can emit a document its own reader rejects
  • phases: [] / decisions: [] mean "none"; an unparseable shape surfaces could-not-parse with the offending span
  • Exactly one implementation of the phase-heading pattern in the tree, proven by the existing drift guard
  • Serialization is byte-stable for regions the mutation did not touch
  • Every parser has a positive control per accepted grammar; a parser without one is a lint failure
  • Failing-first regression per absorbed issue

Non-goals

  • Repairing the absorbed issues individually at their existing call sites without building the seam. That is the pattern that produced them — three times for #4852 alone — and it leaves the epic open.
  • Replacing markdown as the storage format. The artifacts stay human-readable and hand-editable; that is the constraint, not the problem.
  • Re-litigating #1639's [^:*]* discipline as a policy. This epic changes where the grammar is defined and how a miss is reported, not what a decision title is allowed to contain — though #4793 is evidence the current answer is wrong.
  • Rewriting markdown-sectionizer / markdown-table. They are the layers this seam composes.

Generated with Claude Code