---
id: 11573
title: 'DiscussionSyncer: synced markdown lacks `closed`/`closedAt` despite #11554 — V-B-A post-merge verification gap'
state: CLOSED
labels:
  - bug
  - ai
  - model-experience
assignees:
  - neo-opus-ada
createdAt: '2026-05-18T04:41:41Z'
updatedAt: '2026-05-18T07:21:41Z'
githubUrl: 'https://github.com/neomjs/neo/issues/11573'
author: neo-opus-ada
commentsCount: 0
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-18T07:21:41Z'
---
# DiscussionSyncer: synced markdown lacks `closed`/`closedAt` despite #11554 — V-B-A post-merge verification gap

## Context

PR #11555 (commit `c70f2672a`, merged 2026-05-18 00:07Z) shipped #11554's fix adding `closed` + `closedAt` to the Discussion markdown frontmatter at [`ai/services/github-workflow/sync/DiscussionSyncer.mjs:241-250`](https://github.com/neomjs/neo/blob/dev/ai/services/github-workflow/sync/DiscussionSyncer.mjs#L241-L250). Spec at [`test/playwright/unit/ai/services/github-workflow/DiscussionSyncer.spec.mjs:100`](https://github.com/neomjs/neo/blob/dev/test/playwright/unit/ai/services/github-workflow/DiscussionSyncer.spec.mjs#L100) asserts the serialized output carries those fields.

Operator @tobiu V-B-A challenge on 2026-05-18 ~04:30Z: *"do we get a markdown file update in case the CLOSED state changes? i do not see MD files with it inside frontmatter. VBA. this was the real ticket scope."*

## The Problem

Empirical V-B-A confirms operator's observation: **0 of 104 on-disk Discussion markdown files have `closed:` or `closedAt:` in frontmatter**, including `discussion-11557.md` which was *written by Gemini's chore-sync at commit `709d9c44f` AFTER #11554 merged*.

```
$ grep -l "^closed:" resources/content/discussions/chunk-*/*.md | wc -l
0
$ grep -l "^closedAt:" resources/content/discussions/chunk-*/*.md | wc -l
0
$ find resources/content -name "discussion-*.md" | wc -l
104
```

The code emits the fields correctly under test fixtures (spec passes) and under direct `matter.stringify` smoke. But every production-synced file post-#11554 still lacks them.

## The Architectural Reality

Two contributing factors uncovered during V-B-A:

**Factor A — Stale MCP daemon code (operational, likely primary cause):**
- MCP daemons run as long-lived processes in each agent's worktree.
- They load `DiscussionSyncer.mjs` at startup and don't auto-reload on code change.
- If an MCP server was started before #11554 merged, every subsequent `sync_all` from that agent runs the OLD code without the new frontmatter fields.
- `discussion-11557.md` (written by Gemini at `709d9c44f`) suggests Gemini's MCP was running pre-#11554 code despite the merge happening ~1h earlier.

**Factor B — `SyncService` drops `DiscussionSyncer`'s metadata mutations (architectural, latent):**
- [`ai/services/github-workflow/SyncService.mjs:125`](https://github.com/neomjs/neo/blob/dev/ai/services/github-workflow/SyncService.mjs#L125): `await MetadataManager.save(newMetadata)` persists `newMetadata` (returned by `IssueSyncer.pullFromGitHub`).
- `newMetadata` at [`IssueSyncer.mjs:570-574`](https://github.com/neomjs/neo/blob/dev/ai/services/github-workflow/sync/IssueSyncer.mjs#L570-L574) is a fresh object with only `{issues, pushFailures, lastSync}` — it does NOT include `discussions` or `pulls`.
- `DiscussionSyncer.syncDiscussions(metadata)` and `PullRequestSyncer.syncPullRequests(metadata)` mutate the OLD `metadata` (their argument), not `newMetadata`.
- Result: `metadata.discussions = {}` on disk after every sync (verified: 0 entries in `resources/content/.sync-metadata.json` despite 104 on-disk files). Same for `metadata.pulls`.
- This means the discussion-side **diff cache is permanently empty** → every sync treats every discussion as cache-miss → every sync rewrites every discussion file. Wasteful, not yet a correctness bug, but masks the real cache mechanism.

The two factors compound: Factor A drops the fields at write-time; Factor B prevents the cache from helping a later well-running sync detect the missing-fields and force a rewrite.

## The Fix

**One concrete prescription, with two coordinated parts:**

```javascript
// SyncService.mjs:125 — BEFORE save, carry over the mutations DiscussionSyncer + PullRequestSyncer made to metadata.
newMetadata.discussions = metadata.discussions;
newMetadata.pulls       = metadata.pulls;
await MetadataManager.save(newMetadata);
```

Plus a post-sync integrity assertion (new helper or test) that scans a sample of `resources/content/discussions/*.md` for required frontmatter keys (`number`, `closed`, `closedAt`, etc.) and fails the sync if any are missing — closes the V-B-A gap that allowed #11554 to ship without the production effect.

Plus operational: trigger one full `sync_all` from a freshly-started MCP server (after merge of this fix) to force rewrite of all 104 existing discussion files with the new frontmatter.

## Contract Ledger Matrix

| Target Surface | Source of Authority | Proposed Behavior | Fallback | Docs | Evidence |
|---|---|---|---|---|---|
| `SyncService.runFullSync` metadata persistence | This ticket + ADR 0007 compaction taxonomy | Preserve `metadata.discussions` + `metadata.pulls` across `MetadataManager.save` call | Manual JSON edit | JSDoc on `runFullSync` step 10 | Spec test: post-sync `metadata.discussions[N]` populated for each fetched discussion |
| Post-sync frontmatter assertion | This ticket | New helper or test scans `discussions/*.md` for required keys; fails if missing | None | New `verifyFrontmatterIntegrity.mjs` JSDoc | Test: missing-field fixture causes assertion fail |
| Existing 104 markdown files | One-time operational re-sync | All files gain `closed:` + `closedAt:` in frontmatter | None — requires fresh sync | This ticket | `grep -l "^closed:" discussions/chunk-*/*.md | wc -l == 104` after operator-triggered re-sync |

## Acceptance Criteria

- [ ] `SyncService.mjs:125` carries `metadata.discussions` + `metadata.pulls` onto `newMetadata` before save.
- [ ] Unit test verifies `metadata.discussions[N]` survives the full `runFullSync` cycle (or its orchestrator-equivalent in unit form).
- [ ] Post-sync integrity assertion fails when a synced discussion lacks a required frontmatter key (`number`, `closed`, `closedAt`).
- [ ] After operator/peer-triggered re-sync, all 104 on-disk discussion files have `closed:` + `closedAt:` (verified by `grep -l` count = 104).
- [ ] CI evidence: at least one new spec covering the frontmatter-integrity path.

## Out of Scope

- MCP daemon hot-reload (separate substrate; tracked via #11026 / swarm topology).
- Pull request markdown integrity (mirrors Discussion problem but separate scope).
- Release notes integrity (different syncer, different cache).

## Avoided Traps / Gold Standards Rejected

- **Trap:** Rewrite `DiscussionSyncer` to write `metadata.discussions` directly onto `newMetadata` via a shared reference. **Rejected:** breaks the explicit `IssueSyncer.pullFromGitHub` contract that returns a fresh object; better to preserve the post-pull invariant and patch the carry-over at the orchestrator.
- **Trap:** Add lint-like "scan all `.md` files for required frontmatter keys" runner separate from the syncer. **Rejected:** lints run on PRs, not on sync runs; the integrity check needs to fire on the sync path itself so a future degradation gets caught at sync time, not at next-PR time.
- **Trap:** Whole-of-sync rebuild instead of cache-respecting incremental. **Rejected:** the cache is correct in principle; the bug is in metadata persistence, not in the cache mechanism.

## Related

- Original fix: PR #11555 (commit `c70f2672a`, #11554 ticket).
- Empirical anchor: operator V-B-A on 2026-05-18 ~04:30Z confirmed by 0/104 file scan.
- Substrate discipline: `feedback_verify_effect_not_just_success` (post-merge effect verification) + `feedback_verify_before_assert` (multi-layer V-B-A).
- Related orchestration: SyncService.runFullSync — step 10 metadata save is the choke-point.

Origin Session ID: `0526ccc8-019a-4145-84c2-52b27ef09efd`
Retrieval Hint: `query_raw_memories("DiscussionSyncer closed closedAt frontmatter SyncService newMetadata metadata persistence post-merge verification")`

## Timeline

- 2026-05-18T04:41:42Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-18T04:41:42Z @neo-opus-ada added the `bug` label
- 2026-05-18T04:41:42Z @neo-opus-ada added the `ai` label
- 2026-05-18T04:41:42Z @neo-opus-ada added the `model-experience` label
- 2026-05-18T04:48:25Z @neo-opus-ada cross-referenced by PR #11574
- 2026-05-18T05:05:24Z @neo-opus-ada referenced in commit `d82d7ab` - "fix(github-workflow): cycle-2 verifyFrontmatterIntegrity YAML-scoped parsing + throw-on-missing (#11573)

Cycle-1 review (PR #11574) by @neo-gpt empirically caught two concrete
gaps with the integrity gate:

1. Body-line false-positive: the original `^key:` regex matched across
   the whole serialized markdown, so a body line starting with `closed:`
   could falsely satisfy a frontmatter `closed:` check. GPT's V-B-A
   reproduction:
       content = '---"
- 2026-05-18T07:21:41Z @tobiu referenced in commit `ad2afdf` - "fix(github-workflow): DiscussionSyncer + PullRequestSyncer metadata persistence + frontmatter integrity gate (#11573) (#11574)

* fix(github-workflow): carry DiscussionSyncer + PullRequestSyncer metadata onto newMetadata + add frontmatter integrity gate (#11573)

Operator V-B-A on 2026-05-18 caught the empirical bug: 0 of 104 on-disk
Discussion markdown files carry `closed` / `closedAt` despite #11554's
fix having merged at `c70f2672a` ~1h before Gemini's chore-sync ran at
`709d9c44f`. Two compounding factors uncovered:

Factor A (operational, primary): MCP daemons load DiscussionSyncer.mjs
at startup and don't reload on code change. A pre-#11554 MCP server
silently dropped the new frontmatter fields at write time.

Factor B (architectural, latent): `IssueSyncer.pullFromGitHub` returns
a fresh `newMetadata` object carrying only `{issues, pushFailures,
lastSync}`. `DiscussionSyncer.syncDiscussions(metadata)` and
`PullRequestSyncer.syncPullRequests(metadata)` mutate the OLD `metadata`
argument's `.discussions` / `.pulls` fields. Without explicit carry-over
in `SyncService.runFullSync`, those mutations were dropped at
`save(newMetadata)`, leaving `metadata.discussions = {}` and
`metadata.pulls = {}` on disk after every sync. Diff-cache effectively
permanently empty.

Fix:

1. `SyncService.mjs:120-126` — carry `metadata.discussions` +
   `metadata.pulls` onto `newMetadata` before `MetadataManager.save()`.
   Default to `{}` when the syncer left them undefined (early-exit).

2. `verifyFrontmatterIntegrity.mjs` (new pure helper) — line-anchored
   regex check that serialized markdown carries each required frontmatter
   key. Handles malformed input defensively, escapes regex metachars in
   keys, no Neo dependency.

3. `DiscussionSyncer.mjs` — call `verifyDiscussionFrontmatter` after
   `matter.stringify`; log WARN on missing keys naming #11573. Catches
   future Factor A regressions at sync time without failing the run.

4. Test coverage: 12 new test cases (2 in SyncService.Stage2.spec.mjs
   for metadata persistence carry-over; 10 in verifyFrontmatterIntegrity
   .spec.mjs for the helper). 15 total tests pass across the affected
   specs (DiscussionSyncer + SyncService.Stage2 + new helper spec).

Operational follow-up after merge: trigger one fresh sync from a
restarted MCP daemon to force-rewrite all 104 existing discussion files
with the new frontmatter shape.

* fix(github-workflow): cycle-2 verifyFrontmatterIntegrity YAML-scoped parsing + throw-on-missing (#11573)

Cycle-1 review (PR #11574) by @neo-gpt empirically caught two concrete
gaps with the integrity gate:

1. Body-line false-positive: the original `^key:` regex matched across
   the whole serialized markdown, so a body line starting with `closed:`
   could falsely satisfy a frontmatter `closed:` check. GPT's V-B-A
   reproduction:
       content = '---"
- 2026-05-18T07:21:42Z @tobiu closed this issue

