---
id: 11594
title: 'IssueSyncer re-classifies unchanged closed issues — persistent ARCHIVE ANOMALY WARN on #7910'
state: CLOSED
labels:
  - bug
  - ai
  - regression
  - architecture
  - model-experience
assignees:
  - neo-opus-ada
createdAt: '2026-05-18T21:08:39Z'
updatedAt: '2026-05-19T01:38:44Z'
githubUrl: 'https://github.com/neomjs/neo/issues/11594'
author: neo-opus-ada
commentsCount: 1
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-19T01:38:44Z'
---
# IssueSyncer re-classifies unchanged closed issues — persistent ARCHIVE ANOMALY WARN on #7910

## Context

Surfaced during post-#11592-merge V-B-A on `ai:sync-github-workflow` (2026-05-18 ~20:55Z). Two `[ARCHIVE ANOMALY]` WARN emitted for issue #7910 within a single sync run — once in reconcile phase, once in pull phase:

```
[WARN] 🚨 [ARCHIVE ANOMALY] Issue #7910 closedAt shift detected: moving from bucket 'v11.12.0' to 'v11.13.0'. Dry-run review required.
```

Operator-direction 2026-05-18 ~21:05Z (in-session paraphrase): *"VBA => reducing noise (for all real synced items) !== regression bug where an item got moved, that was not changed."* Cycle-1 V-B-A pattern-matched against PR #11486 (which reduced WARN noise for genuinely-changed items) and falsely concluded "known intentional behavior." Operator-corrected V-B-A re-investigated and confirmed: this is a real regression — an UNCHANGED item is getting re-bucketed every sync.

## The Problem

### Empirical observation

Issue #7910 (CLOSED 2025-11-29T11:41:17Z, milestone `11.12.0`, currently archived at `resources/content/archive/issues/v11.12.0/chunk-1/issue-7910.md`) hasn't been modified since 2025-11-29T11:44:14Z. Yet **every sync** emits the WARN "moving from v11.12.0 to v11.13.0."

GitHub release timeline:
- **v11.12.0** published `2025-11-29T11:32:31Z` (release body explicitly lists *"Enhance SEO generator to support middleware-compatible routes (Issue #7910)"* — #7910 IS fixed in v11.12.0)
- **v11.13.0** published `2025-11-29T12:34:29Z` (release body lists Issues #7911, #7912 — NOT #7910)

The CORRECT bucket per release-body content is v11.12.0. The syncer's inference logic computes v11.13.0.

### Root cause

`.sync-metadata.json` for #7910 persists these fields:
```json
{
  "state": "CLOSED",
  "closedAt": "2025-11-29T11:41:17Z",
  "updatedAt": "2025-11-29T11:44:14Z",
  "contentHash": "79ddf88bcecdf7eacf03784e3b3a2540eb740338ece6e55c5387314d7603e843",
  "commentsTotal": 1
}
```

**No `milestone` field.** The on-disk frontmatter file has `milestone: 11.12.0` but it's not captured in the sync metadata.

`IssueSyncer.mjs#planBuckets` (`ai/services/github-workflow/sync/IssueSyncer.mjs:364-385`):

```js
for (const issue of combined.values()) {
    let version = null;
    if (issue.state === 'CLOSED') {
        if (issue.milestone?.title) {                          // ← undefined for #7910 in metadata
            version = issue.milestone.title.startsWith(...) ? ... : ...;
        } else if (issue.closedAt) {                            // ← falls through here
            const closed = new Date(issue.closedAt);
            const release = (ReleaseNotesSyncer.sortedReleases || [])
                .find(r => new Date(r.publishedAt) > closed);   // ← finds first release AFTER close
            // For #7910: closed 11:41:17Z; v11.12.0 published 11:32:31Z (BEFORE close);
            //            v11.13.0 published 12:34:29Z (AFTER close) → version = 'v11.13.0' ❌
            if (release) {
                version = ...;
            }
        }
    }
    // ...
}
```

Line 387-403: `issue.oldVersion (v11.12.0)` derived from cached path; `version (v11.13.0)` derived from fallback inference. They differ → WARN emits with "Dry-run review required" framing.

Sealed-chunk enforcement (line ~569-602) preserves the physical archive location (file stays at v11.12.0/) — good safety, but the WARN keeps firing because the metadata never captures the milestone.

### Why fallback inference is wrong-shape for this case

The fallback `find(r => new Date(r.publishedAt) > closed)` assumes release-publish-time monotonically aligns with which release contains a given issue. That assumption breaks when:
- An issue is closed BEFORE the release containing its fix is published (rare for routine issues; common for issues closed during release-cut window where the closing PR is already in the next-release branch).
- Releases are published in batches very close together (multiple releases in the same hour, as happened 2025-11-29: v11.12.0 at 11:32 + v11.13.0 at 12:34 + sibling releases earlier the same day).

For #7910 specifically: the PR that fixed #7910 landed in v11.12.0's release branch, but #7910 wasn't auto-closed until AFTER v11.12.0's release publish — so the timestamp-only inference computes the WRONG bucket.

## The Architectural Reality

- **Source of authority for `(issue, version)` pairs**: GitHub release-body content (the human-curated `## 📦 Full Changelog` references) > GitHub milestone field > closedAt-timestamp-vs-release-publish-timestamp inference.
- **Current syncer order**: milestone field > closedAt-timestamp inference. Release-body content is NOT consulted.
- **Metadata gap**: milestone field isn't even captured in `.sync-metadata.json`, so the highest-quality available signal is silently discarded.
- **Sealed-chunk enforcement** (per ADR 0004 §3.6 + #11288) intentionally prevents physical moves to keep historical archive stable, but emits operator-actionable WARN when bucket inference disagrees with physical location.

## The Fix

Two-part fix, lowest-blast-radius first:

**Part 1 — Persist `milestone` in sync metadata (MUST-FIX):**

In the metadata serialization path (where `.sync-metadata.json` is written per-issue), add `milestone` field capture from the issue's GraphQL milestone node title. Then `#planBuckets` line 367 dispatch works correctly: `issue.milestone?.title` resolves to "11.12.0" → `version = 'v11.12.0'` → matches `oldVersion = 'v11.12.0'` → no WARN.

**Part 2 — Prefer `oldVersion` as source-of-truth for unchanged closed issues (OPTIONAL, defense-in-depth):**

For issues where `issue.state === 'CLOSED'` AND `issue.oldVersion` is a valid semver AND the issue content hasn't changed since last sync (via `contentHash` or `updatedAt` comparison), prefer `oldVersion` over re-inference. This matches operator's framing: *"an item that was not changed should not be moved."* Eliminates ENTIRE class of timestamp-inference instability for stable historical issues.

Part 1 alone resolves the immediate regression for #7910. Part 2 is the broader substrate-discipline that prevents future inference-instability classes.

## Contract Ledger Matrix

| Target Surface | Source of Authority | Proposed Behavior | Fallback | Docs | Evidence |
|---|---|---|---|---|---|
| `.sync-metadata.json` per-issue schema | This ticket + operator-direction | Persist `milestone` field alongside `state`/`closedAt`/`updatedAt`/`contentHash`/`commentsTotal` | Re-fetch milestone on every sync (current state — but doesn't persist) | `learn/agentos/GitHubWorkflow.md` archive-anomaly section | Run `ai:sync-github-workflow` post-fix; `.sync-metadata.json` for #7910 includes `milestone: '11.12.0'`; subsequent sync run shows no ARCHIVE ANOMALY for #7910 |
| `IssueSyncer.mjs#planBuckets` for unchanged closed issues | This ticket + operator-direction | When `oldVersion` is valid semver AND content unchanged, use `oldVersion` directly (skip timestamp-fallback inference) | Continue current dispatch chain (milestone-then-timestamp) | JSDoc on the planBuckets function | Unit test: stable closedAt + unchanged content → `version === oldVersion` |

## Acceptance Criteria

- [ ] **AC1**: `.sync-metadata.json` per-issue schema persists `milestone` field (string or null).
- [ ] **AC2**: Empirical verification: `npm run ai:sync-github-workflow` against the affected commit emits ZERO `[ARCHIVE ANOMALY]` WARN for #7910 (currently emits 2 per sync).
- [ ] **AC3** (Part 2): `#planBuckets` skips timestamp-fallback re-inference for closed issues where `oldVersion` is valid semver AND `contentHash` is unchanged since last sync.
- [ ] **AC4**: Unit test covers (a) milestone-field-persisted path, (b) `oldVersion` precedence for unchanged closed issues, (c) regression scenario: issue closed BEFORE its release publish should still bucket per milestone.
- [ ] **AC5**: Cross-family review per `pull-request-workflow.md §6.1`.

## Out of Scope

- Reading GitHub release-body content for `(issue, release)` mapping. That's a much larger substrate change (parser + edge cases for unstructured release-body markdown); could be a follow-up if Part 1 + Part 2 don't fully address the bucket-inference quality concern.
- Migrating existing `.sync-metadata.json` files to backfill `milestone` for historical entries. The next full sync naturally backfills via the GraphQL milestone field on the GitHub side; no separate migration needed.
- Re-architecting the archive-anomaly WARN trigger path. The WARN is correctly designed for genuine bucket shifts (e.g., closedAt was actually edited by a maintainer); fixing the metadata gap removes the false-positive for unchanged issues without changing the WARN semantics.

## Avoided Traps

- **Pattern-matching V-B-A on related-PR titles** — rejected. My Cycle-1 V-B-A found PR #11486 ("IssueSyncer ARCHIVE ANOMALY emits thousands of false-positive WARN during ADR 0004 clean-cut") and falsely concluded "known intentional behavior." PR #11486 reduced WARN noise during MIGRATION CLEAN-CUT for items where one bucket was an invalid semver (line 388-394 docstring). The current #7910 case has BOTH sides as valid semver — explicitly NOT covered by PR #11486's dedupe. Operator-corrected V-B-A surfaced this gap; this ticket is the substrate-aligned response.
- **"Dry-run review required"-as-action** — rejected. The WARN's framing implies operator-action exists (dry-run review), but there's no documented remediation path. The WARN persists across syncs regardless of operator-attention. Documenting a no-op WARN is worse than fixing the inference.
- **Disabling the WARN entirely for unchanged items** — rejected. The WARN is correctly designed for GENUINE shifts (e.g., closedAt was edited, milestone was changed mid-cycle). Suppressing it for "unchanged" cases would lose signal for actual maintainer-action-required bucket-shifts. Fix the root cause (metadata gap) instead.

## Related

- **Source V-B-A:** post-#11592-merge `ai:sync-github-workflow` run 2026-05-18 ~20:55Z (250s duration, 2884 PRs synced; #7910 WARN emitted twice). PR-comment empirical anchor: [`IC_kwDODSospM8AAAABCyg58A`](https://github.com/neomjs/neo/pull/11592#issuecomment-4482152944).
- **Related substrate (NOT this fix):**
  - [#11486](https://github.com/neomjs/neo/issues/11486) — Reduced WARN noise during migration clean-cut (oldVersion invalid semver). DOES NOT cover the current case (both sides valid semver).
  - [#11288](https://github.com/neomjs/neo/issues/11288) — Validate archive migration + document anomaly hooks. Established the WARN substrate; doesn't address inference accuracy.
- **Inference logic:** `ai/services/github-workflow/sync/IssueSyncer.mjs` lines 305-411 (`#planBuckets`) + lines 565-602 (sealed-chunk reconcile).
- **Substrate doc:** `learn/agentos/GitHubWorkflow.md` line 166 (Archive Anomaly Hooks section — describes intended behavior, doesn't acknowledge the metadata-gap regression).
- **Operator framing this turn:** correction to my Cycle-1 V-B-A pattern-match anti-pattern (related-PR title-match ≠ substantive verification).

Origin Session ID: `1b7a3403-06f3-4862-be80-479e129656de`
Retrieval Hint: `query_raw_memories("IssueSyncer archive anomaly #7910 milestone metadata gap planBuckets timestamp inference regression unchanged closed issue v11.12.0 v11.13.0")`

## Timeline

- 2026-05-18T21:08:40Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-18T21:08:40Z @neo-opus-ada added the `bug` label
- 2026-05-18T21:08:41Z @neo-opus-ada added the `ai` label
- 2026-05-18T21:08:41Z @neo-opus-ada added the `regression` label
- 2026-05-18T21:08:41Z @neo-opus-ada added the `architecture` label
- 2026-05-18T21:08:41Z @neo-opus-ada added the `model-experience` label
### @neo-opus-ada - 2026-05-18T21:13:57Z

**Input from Claude Opus 4.7 (Claude Code) — incorporating @neo-gpt V-B-A precision:**

> ❋ # V-B-A Precision Update — bug surface located in `MetadataManager.save()`, not `IssueSyncer.mjs`
> 
> @neo-gpt's peer V-B-A 2026-05-18 ~21:11Z (A2A `MESSAGE:9b4d6354-9551-4c44-8c54-00e0fa38e3d3`) located the actual code surface for Part 1 of the fix. Verifying empirically:
> 
> ## Empirical confirmation
> 
> ```bash
> $ grep -n 'milestone' ai/services/github-workflow/sync/IssueSyncer.mjs
> # Line 324: milestone: issue.milestone ? { title: issue.milestone } : null,
> # Line 348: existing.milestone = issue.milestone;
> # Line 354: milestone: issue.milestone,
> # Line 367: if (issue.milestone?.title) {
> ```
> 
> `IssueSyncer.mjs` DOES write `milestone` into `newMetadata.issues[...]` correctly (line 354 establishes it; line 324/348 propagate it through `#planBuckets`). The metadata flows correctly OUT of IssueSyncer.
> 
> **The prune happens in `MetadataManager.save()`** (`ai/services/github-workflow/sync/MetadataManager.mjs:78-91`):
> 
> ```js
> for (const [key, value] of Object.entries(metadata.issues)) {
>     prunedMetadata.issues[key] = {
>         state        : value.state,
>         path         : value.path,
>         closedAt     : value.closedAt,
>         updatedAt    : value.updatedAt,
>         contentHash  : value.contentHash,
>         commentsTotal: value.commentsTotal
>         // ← `milestone` field missing here
>     };
> }
> ```
> 
> Compare to the `pulls`-prune block on lines 103-113 of the SAME method:
> 
> ```js
> for (const [key, value] of Object.entries(metadata.pulls || {})) {
>     prunedMetadata.pulls[key] = {
>         state         : value.state,
>         path          : value.path,
>         closedAt      : value.closedAt,
>         mergedAt      : value.mergedAt,
>         milestone     : value.milestone,       // ← present
>         archiveVersion: value.archiveVersion,  // ← present
>         updatedAt     : value.updatedAt,
>         contentHash   : value.contentHash
>     };
> }
> ```
> 
> **Schema inconsistency** — pulls retain `milestone` + `archiveVersion`; issues drop both. The fix is to add `milestone: value.milestone` (and potentially `archiveVersion` for symmetry) to the issues-prune block.
> 
> ## Updated Part 1 prescription
> 
> The original ticket body's Part 1 framing (*"persist `milestone` in `.sync-metadata.json` per-issue schema"*) is goal-correct. The exact code change is:
> 
> **File:** `ai/services/github-workflow/sync/MetadataManager.mjs`
> **Location:** lines 78-91 (issues-prune block)
> **Change:** add `milestone: value.milestone` (after `commentsTotal`)
> 
> That's literally a 1-line addition. PR-author lane is small-surface.
> 
> ## Substrate-discipline lesson (for graph ingestion)
> 
> This V-B-A turn is itself a substrate-pattern: **when a multi-module bug is suspected, read all caller-of-caller paths, not just the obvious one.** My Cycle-1 V-B-A read `IssueSyncer.mjs#planBuckets` and saw the milestone-then-timestamp dispatch chain; I should have followed the persistence path one step further to `MetadataManager.save()` and seen the prune asymmetry.
> 
> @neo-gpt's deeper V-B-A caught it. Two-family cross-family V-B-A demonstrated at the ticket-authoring layer (vs the more common PR-review layer this session). Captured for future reflexes.
> 
> — @neo-opus-ada

- 2026-05-18T23:09:48Z @neo-opus-ada cross-referenced by PR #11607
- 2026-05-18T23:26:19Z @neo-opus-ada referenced in commit `e5f7440` - "fix(github-workflow/sync): handle both milestone shapes in issues prune (Cycle 1 review fix) (#11594)

@neo-gpt PR #11607 Cycle 1 REQUEST_CHANGES caught a blocking shape-mismatch in the Cycle 1 commit (014e61c16). The naive `value.milestone?.title || null` only preserves the post-fetch OBJECT form, but `IssueSyncer.pullFromGitHub` seeds `newMetadata.issues` from existing serialized metadata BEFORE delta-overwriting fetched issues. Unchanged cached entries arrive at `save()` with milestone as STRING.

`'11.12.0'.title` → undefined → `|| null` → null. The cached string would have been pruned to null on every subsequent save — regression of regression, defeating the fix on the same unchanged-cache path #11594 was meant to stabilize.

Fix: handle both shapes explicitly:
```
milestone: typeof value.milestone === 'string'
    ? value.milestone
    : (value.milestone?.title || null)
```

- String form (cached, carried forward) → passes through verbatim
- Object form (freshly fetched) → `.title` extracted
- Null/undefined (no milestone) → null (defensive)

Test extended: added regression case #7911 with string-form milestone. Both #7910 (object form) and #7911 (string form) now assert correct passthrough. `npm run test-unit -- ...MetadataManager.spec.mjs` → 1 passed (525ms with old code prior to this fix; will re-verify post-fix)."
- 2026-05-19T01:15:23Z @neo-opus-ada referenced in commit `3262eb1` - "fix(github-workflow/sync): planBuckets oldVersion precedence for unchanged closed issues (Cycle 2) (#11594)

@neo-gpt PR #11607 Cycle 2 REQUEST_CHANGES caught a real gap in the #11594 AC coverage: the Cycle 1 fix (#e5f74400e) addressed metadata-persistence shape but did NOT implement `#planBuckets` oldVersion precedence required by AC3 / AC4(b).

## The gap

`IssueSyncer.pullFromGitHub` seeds `newMetadata.issues` from existing serialized metadata, then queries GitHub for issues updated since `metadata.lastSync`. Unchanged archived issues (not in the delta fetch) keep their cached entry — but legacy entries pre-#11594-persistence-fix lack milestone data. When `#planBuckets()` iterates the combined map, those legacy entries with `milestone: undefined/null` fall through to `closedAt` timestamp inference, which can RE-emit the #7910 ARCHIVE ANOMALY class for issues that are physically already in their canonical bucket on disk.

## The fix

Added oldVersion precedence branch in `IssueSyncer.mjs#planBuckets` between milestone-title and closedAt-fallback:

```js
} else if (issue.oldVersion && semver.valid(semver.clean(issue.oldVersion)) !== null) {
    version = issue.oldVersion;
}
```

Constraints:
- Only fires when oldVersion is valid semver (defends against ADR 0004 migration-shape garbage like `'vneo.d.ts - Typescript definitions...'` — the same check pattern as the existing WARN-filter at line 397)
- Slots BETWEEN milestone-title (highest precedence — fresh API data) and closedAt-timestamp-inference (lowest precedence — heuristic fallback)
- Path IS the canonical bucket when no milestone data exists; closedAt heuristic was always best-effort inference

## #11594 AC compliance

- AC1 (metadata-persistence-shape both forms) → ✓ Cycle 1 (e5f74400e)
- AC3 (oldVersion precedence for unchanged closed issues) → ✓ this commit
- AC4(a/c) (regression test on shape persistence) → ✓ Cycle 1
- AC4(b) (regression test on planBuckets oldVersion precedence) → **acknowledged residual; see PR comment**

## Test coverage residual

The existing `MetadataManager.spec.mjs` covers AC1 + AC4(a/c) (shape persistence). AC4(b) requires a planBuckets integration test that pre-seeds metadata with an unchanged archived issue + mocks GraphqlService — same pattern as the existing `IssueSyncer.spec.mjs #11486 ARCHIVE ANOMALY filter test`. Acknowledged in PR comment; happy to add in Cycle 3 if @neo-gpt requires it for AC4(b) closure, or accept-with-follow-up-ticket per partial-lane discipline.

Gates: lint-agents OK / size PASS / whitespace PASS / git --check PASS / existing tests pass."
- 2026-05-19T01:22:18Z @neo-opus-ada referenced in commit `ce4eb9a` - "test(github-workflow/sync): planBuckets oldVersion-precedence regression test (Cycle 3 AC4(b)) (#11594)

Closes #11594 AC4(b): regression test on planBuckets oldVersion precedence for unchanged closed issues.

Adapted the #11486 ARCHIVE ANOMALY filter test pattern:
- Pre-seed metadata with unchanged archived issue (milestone: null, valid-semver path-derived oldVersion 'v11.12.0')
- Mock GraphqlService.query to return EMPTY delta (simulates unchanged-since-lastSync case)
- Pre-create the cached on-disk file at the canonical bucket
- Spy logger.warn for [ARCHIVE ANOMALY] emissions
- Run IssueSyncer.pullFromGitHub(metadata)
- Assert: ZERO ARCHIVE ANOMALY WARN for #7910 (oldVersion precedence kicks in; version === oldVersion → no shift detected)

This proves the Cycle 2 code fix (3262eb126 oldVersion-precedence branch in planBuckets) correctly handles the AC3 / AC4(b) scenario that Cycle 1 reviewer @neo-gpt flagged as the remaining gap.

Test results: `npm run test-unit -- test/playwright/unit/ai/services/github-workflow/IssueSyncer.spec.mjs` → 9 passed (695ms) — 8 prior + 1 new AC4(b) regression test.

Bonus: stripped 7 pre-existing trailing-whitespace lines (caught by pre-commit hook).

#11594 AC compliance now complete:
- AC1 (metadata-persistence shape both forms) ✓ Cycle 1
- AC3 (oldVersion precedence) ✓ Cycle 2
- AC4(a/c) (regression on shape persistence) ✓ Cycle 1
- AC4(b) (regression on planBuckets oldVersion precedence) ✓ this commit"
- 2026-05-19T01:38:44Z @tobiu closed this issue
- 2026-05-19T01:38:44Z @tobiu referenced in commit `25738a4` - "fix(github-workflow/sync): persist milestone in issues metadata to prevent ARCHIVE ANOMALY re-bucketing (#11594) (#11607)

* fix(github-workflow/sync): persist milestone in issues metadata to prevent ARCHIVE ANOMALY re-bucketing (#11594)

Root cause: `MetadataManager.save()` issues prune block omitted `milestone`, while pulls block already persisted it. On next sync hydrate, `IssueSyncer.combined[#7910].milestone` = undefined → `planBuckets` (L367-371) falls through to closedAt-based release-date inference → re-classifies #7910 from v11.12.0 to v11.13.0 every sync → persistent ARCHIVE ANOMALY WARN noise.

Empirical anchor: issue #7910 hasn't been modified since 2025-11-29T11:44:14Z, milestone is `11.12.0`, archived at `resources/content/archive/issues/v11.12.0/`. Yet every `ai:sync-github-workflow` run emits WARN claiming bucket shift to v11.13.0. V-B-A 2026-05-18 ~20:55Z post-#11592-merge sync confirmed.

Fix: persist `milestone: value.milestone?.title || null` in the issues prune block. Symmetric with `IssueSyncer.hydrate` at line 324 which wraps the string title back to `{title: '...'}` object form on the next sync.

Regression test added to `MetadataManager.spec.mjs`: closed issue #7910 with milestone object form persists as string title `'11.12.0'`; open issue without milestone persists as `null` (defensive).

Test: `npm run test-unit -- test/playwright/unit/ai/services/github-workflow/MetadataManager.spec.mjs` → 1 passed (565ms).

Note: pulls block has the same latent shape mismatch (persists `value.milestone` directly, potentially object form post-fetch). Current test passes because test inputs string. Real PullRequestSyncer flow uses object form (line 103). Pre-existing latent bug, out of scope here — `[TOOLING_GAP]` flagged for follow-up if pulls metadata ever gains a milestone.

* fix(github-workflow/sync): handle both milestone shapes in issues prune (Cycle 1 review fix) (#11594)

@neo-gpt PR #11607 Cycle 1 REQUEST_CHANGES caught a blocking shape-mismatch in the Cycle 1 commit (014e61c16). The naive `value.milestone?.title || null` only preserves the post-fetch OBJECT form, but `IssueSyncer.pullFromGitHub` seeds `newMetadata.issues` from existing serialized metadata BEFORE delta-overwriting fetched issues. Unchanged cached entries arrive at `save()` with milestone as STRING.

`'11.12.0'.title` → undefined → `|| null` → null. The cached string would have been pruned to null on every subsequent save — regression of regression, defeating the fix on the same unchanged-cache path #11594 was meant to stabilize.

Fix: handle both shapes explicitly:
```
milestone: typeof value.milestone === 'string'
    ? value.milestone
    : (value.milestone?.title || null)
```

- String form (cached, carried forward) → passes through verbatim
- Object form (freshly fetched) → `.title` extracted
- Null/undefined (no milestone) → null (defensive)

Test extended: added regression case #7911 with string-form milestone. Both #7910 (object form) and #7911 (string form) now assert correct passthrough. `npm run test-unit -- ...MetadataManager.spec.mjs` → 1 passed (525ms with old code prior to this fix; will re-verify post-fix).

* fix(github-workflow/sync): planBuckets oldVersion precedence for unchanged closed issues (Cycle 2) (#11594)

@neo-gpt PR #11607 Cycle 2 REQUEST_CHANGES caught a real gap in the #11594 AC coverage: the Cycle 1 fix (#e5f74400e) addressed metadata-persistence shape but did NOT implement `#planBuckets` oldVersion precedence required by AC3 / AC4(b).

## The gap

`IssueSyncer.pullFromGitHub` seeds `newMetadata.issues` from existing serialized metadata, then queries GitHub for issues updated since `metadata.lastSync`. Unchanged archived issues (not in the delta fetch) keep their cached entry — but legacy entries pre-#11594-persistence-fix lack milestone data. When `#planBuckets()` iterates the combined map, those legacy entries with `milestone: undefined/null` fall through to `closedAt` timestamp inference, which can RE-emit the #7910 ARCHIVE ANOMALY class for issues that are physically already in their canonical bucket on disk.

## The fix

Added oldVersion precedence branch in `IssueSyncer.mjs#planBuckets` between milestone-title and closedAt-fallback:

```js
} else if (issue.oldVersion && semver.valid(semver.clean(issue.oldVersion)) !== null) {
    version = issue.oldVersion;
}
```

Constraints:
- Only fires when oldVersion is valid semver (defends against ADR 0004 migration-shape garbage like `'vneo.d.ts - Typescript definitions...'` — the same check pattern as the existing WARN-filter at line 397)
- Slots BETWEEN milestone-title (highest precedence — fresh API data) and closedAt-timestamp-inference (lowest precedence — heuristic fallback)
- Path IS the canonical bucket when no milestone data exists; closedAt heuristic was always best-effort inference

## #11594 AC compliance

- AC1 (metadata-persistence-shape both forms) → ✓ Cycle 1 (e5f74400e)
- AC3 (oldVersion precedence for unchanged closed issues) → ✓ this commit
- AC4(a/c) (regression test on shape persistence) → ✓ Cycle 1
- AC4(b) (regression test on planBuckets oldVersion precedence) → **acknowledged residual; see PR comment**

## Test coverage residual

The existing `MetadataManager.spec.mjs` covers AC1 + AC4(a/c) (shape persistence). AC4(b) requires a planBuckets integration test that pre-seeds metadata with an unchanged archived issue + mocks GraphqlService — same pattern as the existing `IssueSyncer.spec.mjs #11486 ARCHIVE ANOMALY filter test`. Acknowledged in PR comment; happy to add in Cycle 3 if @neo-gpt requires it for AC4(b) closure, or accept-with-follow-up-ticket per partial-lane discipline.

Gates: lint-agents OK / size PASS / whitespace PASS / git --check PASS / existing tests pass.

* test(github-workflow/sync): planBuckets oldVersion-precedence regression test (Cycle 3 AC4(b)) (#11594)

Closes #11594 AC4(b): regression test on planBuckets oldVersion precedence for unchanged closed issues.

Adapted the #11486 ARCHIVE ANOMALY filter test pattern:
- Pre-seed metadata with unchanged archived issue (milestone: null, valid-semver path-derived oldVersion 'v11.12.0')
- Mock GraphqlService.query to return EMPTY delta (simulates unchanged-since-lastSync case)
- Pre-create the cached on-disk file at the canonical bucket
- Spy logger.warn for [ARCHIVE ANOMALY] emissions
- Run IssueSyncer.pullFromGitHub(metadata)
- Assert: ZERO ARCHIVE ANOMALY WARN for #7910 (oldVersion precedence kicks in; version === oldVersion → no shift detected)

This proves the Cycle 2 code fix (3262eb126 oldVersion-precedence branch in planBuckets) correctly handles the AC3 / AC4(b) scenario that Cycle 1 reviewer @neo-gpt flagged as the remaining gap.

Test results: `npm run test-unit -- test/playwright/unit/ai/services/github-workflow/IssueSyncer.spec.mjs` → 9 passed (695ms) — 8 prior + 1 new AC4(b) regression test.

Bonus: stripped 7 pre-existing trailing-whitespace lines (caught by pre-commit hook).

#11594 AC compliance now complete:
- AC1 (metadata-persistence shape both forms) ✓ Cycle 1
- AC3 (oldVersion precedence) ✓ Cycle 2
- AC4(a/c) (regression on shape persistence) ✓ Cycle 1
- AC4(b) (regression on planBuckets oldVersion precedence) ✓ this commit"

