---
id: 11554
title: Sync `closed` + `closedAt` to Discussion frontmatter in DiscussionSyncer
state: CLOSED
labels:
  - documentation
  - enhancement
  - ai
  - build
assignees:
  - neo-opus-ada
createdAt: '2026-05-17T23:48:12Z'
updatedAt: '2026-06-07T17:12:57Z'
githubUrl: 'https://github.com/neomjs/neo/issues/11554'
author: neo-opus-ada
commentsCount: 0
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-18T00:07:44Z'
---
# Sync `closed` + `closedAt` to Discussion frontmatter in DiscussionSyncer

## Context

Surfaced 2026-05-18 during post-#11553 review when reading synced Discussion `#10520` (Anti-Rubber-Stamp PR Defense) — the synced markdown's frontmatter lacks any indicator of open/closed state, while issue frontmatter does carry it. This breaks symmetry between synced Issue and Discussion artifacts and forces a live GraphQL re-query to determine whether a Discussion is closed.

## The Problem

The agent-facing synced Discussion files in `resources/content/discussions/**/discussion-*.md` are an A2A memory bridge — future agents read them via `view_file` or grep to assess substrate state without paying GraphQL round-trip cost. Today, an agent reading the frontmatter cannot answer "is this Discussion still open?" without an additional API call.

Concrete observation (synced `resources/content/discussions/chunk-1/discussion-10520.md`):

```yaml
---
number: 10520
title: >-
  Architecture: Codify Anti-Rubber-Stamp PR Defense Protocol (Extract Review Response)
author: neo-gemini-pro
category: Ideas
createdAt: '2026-04-30T09:17:14Z'
updatedAt: '2026-04-30T19:51:34Z'
---
```

No `closed` or `closedAt` field — yet `#10520` was CLOSED 2026-04-30 (resolved-graduated).

## The Architectural Reality

The fields are **already fetched** by the GraphQL query — `ai/services/github-workflow/queries/discussionQueries.mjs:82-83` requests both `closed` and `closedAt`, and other paths within `DiscussionSyncer` use them (release-mapping at lines 91, 99, 109, 322-323).

The gap is purely at the frontmatter-object construction step. `ai/services/github-workflow/sync/DiscussionSyncer.mjs:241-248`:

```javascript
const frontmatter = {
    number     : discussion.number,
    title      : discussion.title,
    author     : discussion.author?.login || 'unknown',
    category   : discussion.category?.name || 'Uncategorized',
    createdAt  : discussion.createdAt,
    updatedAt  : discussion.updatedAt
};
```

`closed` + `closedAt` are dropped at this serialization step despite being available on `discussion`.

## The Fix

Two-line addition at `DiscussionSyncer.mjs:241-248`:

```javascript
const frontmatter = {
    number     : discussion.number,
    title      : discussion.title,
    author     : discussion.author?.login || 'unknown',
    category   : discussion.category?.name || 'Uncategorized',
    createdAt  : discussion.createdAt,
    updatedAt  : discussion.updatedAt,
    closed     : discussion.closed,
    closedAt   : discussion.closedAt
};
```

The next sync-pipeline run will regenerate all synced Discussion `.md` files with the new fields populated (the content-hash-comparison loop at line ~280 will detect the frontmatter delta and rewrite every file). No backfill script required; the existing pipeline does the work.

## Acceptance Criteria

- [ ] `frontmatter` object in `DiscussionSyncer.mjs` includes `closed` + `closedAt` keys.
- [ ] Unit-test coverage at `test/playwright/unit/ai/services/github-workflow/` asserts the two new fields appear in the serialized frontmatter (if existing `DiscussionSyncer.spec.mjs` tests frontmatter shape; otherwise add a focused assertion).
- [ ] One sample synced file (`resources/content/discussions/chunk-1/discussion-10520.md` or similar) post-sync shows `closed: true` + `closedAt: <timestamp>` for a known-closed Discussion (e.g., `#10520`, `#11168`).
- [ ] Issue-frontmatter parity check: confirm the field naming aligns with how Issue frontmatter exposes its state field (avoid divergence between `state` for Issues vs `closed` for Discussions). If divergent, document the rationale in the PR (Discussions don't have a `state` GraphQL field; `closed` is the canonical Discussion shape).

## Out of Scope

- **Manual backfill of synced files.** The hourly sync pipeline will repopulate all Discussion `.md` files on its next run.
- **State-reason / lock-reason fields** (e.g., `stateReason: 'RESOLVED'`). GraphQL exposes these for Discussions, but the immediate gap is binary open/closed.
- **Issue-side frontmatter changes.** Issue frontmatter already carries `state` per recent sync runs; no symmetric change needed there.

## Avoided Traps

- **`state: open|closed` instead of `closed: bool`.** Rejected — Discussions' GraphQL type does not expose a `state` enum field (verified empirically; gh CLI errors with "Field 'state' doesn't exist on type 'Discussion'"). The canonical shape is the `closed` boolean. Adopting `closed` matches the GraphQL contract and avoids re-deriving an enum that doesn't exist upstream.
- **Adding the fields to the GraphQL query.** Not needed — both fields are already fetched at `discussionQueries.mjs:82-83`. The fix is at the frontmatter-construction step only.

## Related

- `ai/services/github-workflow/sync/DiscussionSyncer.mjs` (the fix surface)
- `ai/services/github-workflow/queries/discussionQueries.mjs` (already fetches the fields)
- Issue-side frontmatter sync (parity reference; serves as the discipline anchor for "synced markdown should carry state")

Origin Session ID: 39eee906-3fd4-424f-9348-828b46ece38c
Handoff Retrieval Hint: "DiscussionSyncer frontmatter closed closedAt sync field gap"

## Timeline

- 2026-05-17T23:48:13Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-17T23:48:13Z @neo-opus-ada added the `documentation` label
- 2026-05-17T23:48:13Z @neo-opus-ada added the `enhancement` label
- 2026-05-17T23:48:14Z @neo-opus-ada added the `ai` label
- 2026-05-17T23:48:14Z @neo-opus-ada added the `build` label
- 2026-05-17T23:51:20Z @neo-opus-ada cross-referenced by PR #11555
- 2026-05-18T00:07:44Z @tobiu referenced in commit `c70f267` - "fix(github-workflow): sync closed + closedAt to Discussion frontmatter (#11554) (#11555)

The GraphQL Discussion query at discussionQueries.mjs:82-83 already fetches closed + closedAt, but the frontmatter object construction at DiscussionSyncer.mjs:241-248 dropped both fields. Synced Discussion markdown therefore could not signal open/closed state without a live GraphQL re-query, breaking parity with the synced Issue frontmatter (which carries state).

Adds the two fields to the frontmatter object. Tests at DiscussionSyncer.spec.mjs assert the new fields appear in the serialized output for both active (closed: false) and archived (closed: true + closedAt: timestamp) fixtures.

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-18T00:07:44Z @tobiu closed this issue
- 2026-05-18T04:41:42Z @neo-opus-ada cross-referenced by #11573
- 2026-05-18T04:48:25Z @neo-opus-ada cross-referenced by PR #11574
- 2026-05-18T07:21:42Z @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-20T20:02:43Z @neo-gpt cross-referenced by #11693

