Context
The discussion sync fails with Resource limits for this query exceeded and has re-paged the entire discussion history on every scheduled run. Surfaced from a live local syncGithubWorkflow, operator-reported.
Body rewritten in place 2026-07-26 per @neo-gpt-emmy's cycle-1 RA-2. It previously prescribed adaptive page-size degradation, which is withdrawn — see Out of Scope. The prior prescription and the two superseded AC sets are preserved in this ticket's comment history; this body is the live contract.
The Problem
The delta cutoff has never engaged. It is computed from updatedAt on cached entries:
const cachedDiscDates = Object.values(metadata.discussions || {})
.map(d => Date.parse(d.updatedAt)).filter(t => !isNaN(t));
const sinceCutoff = (metadata.lastSync == null || cachedDiscDates.length === 0) ? 0 : …That field was never written. Date.parse(undefined) → NaN → filtered out → the date list is empty → sinceCutoff is always 0 → the UPDATED_AT-descending early break can never fire. So every run re-pages all ~210 discussions and pays full GraphQL cost for a corpus that has not changed.
Measured in live resources/content/.sync-metadata.json:
| facet |
entries |
carrying updatedAt |
| discussions |
210 |
0 |
| issues |
10,380 |
10,380 |
The issue syncer has always persisted it. This is the discussion side catching up.
The cost, measured rather than asserted. rateLimit(dryRun:true) on the real query: 15 points per 30 discussions, so a 210-discussion traversal is ~105 points, hourly, indefinitely, against a 5,000/hour ceiling that the corpus exists to protect. The per-query cost ceiling is the symptom; this is why it is reached at all.
The Architectural Reality
Three row producers, not one
The field has to be emitted by every writer of a cache row, and there are three:
| producer |
site |
role |
| bulk repopulation |
DiscussionSyncer:569 |
writes the row for every fetched discussion |
| force-refetch recovery |
DiscussionSyncer:675 |
OVERWRITES the row — omitting the field strips it from every discussion it repairs |
| persistence prune |
MetadataManager:170 |
decides what survives a save/load round trip |
The recovery one is the dangerous omission: a repair pass that lowers the cutoff, and zeroes it once enough rows lose the field, reintroduces the exact defect it recovered from.
The matched pair — persisting the field alone is WORSE than the bug
The bulk repopulation also did metadata.discussions = {} and rebuilt only from what the run fetched. That is harmless only because the zero cutoff makes the fetch the whole corpus.
Make the delta work without converting that replace to a merge, and every entry it skips loses its path and contentHash, misses the unchanged-content shortcut, and is rewritten on every subsequent run — a permanently non-empty diff in a tracked generated corpus, which would look like a successful fix. The two changes are one change.
A job the wipe was doing silently
Denylist containment relied on the wholesale reset to drop a quarantined discussion's metadata row. So the wipe served two unrelated purposes and only one was documented. Containment must clear file, index entry and metadata row explicitly, independently of how the cache is rebuilt.
The Fix
- All three producers emit
updatedAt.
metadata.discussions is merged, never replaced — required in the same change as (1).
- Denylist containment removes the metadata row explicitly.
- Page size unchanged. With the delta engaged a normal run fetches a handful of discussions.
Acceptance Criteria
Delivered — each with a mutation-discriminating witness:
Live-only residuals — L3, unobtainable from any unmerged head:
Evidence: achieved L1; required for closure L3. The code path is proven; the corpus behaviour is not, and this ticket does not close until it is.
Out of Scope
- Adaptive page-size degradation — WITHDRAWN. It was priced against the hourly rate limit, which is not the binding constraint (~105 points against 5,000/hour). The binding limit was the per-query cost ceiling, and the delta fix removes the reason it is reached.
- Comment/reply pagination to completeness →
#16016. No code path fetches past 50 comments / 20 replies, so a long thread's 51st comment has never been in the corpus and the recovery path re-truncates it. That is data loss, a different failure mode from fetch cost, and it was mis-filed here.
#16002 / #16010 — facet isolation and within-facet resume.
- Ruleset / credential work —
#15972, #16007.
Avoided Traps
- Treating the page size as the defect. It is the surface the failure appears on. A smaller page would have made the symptom intermittent and left ~210 discussions re-fetched hourly forever.
- Persisting
updatedAt without the merge. Trades a loud failure for permanent silent churn in a tracked generated corpus — strictly worse, and it would look like a fix.
- Stopping at the producers you found. I wrote "two omission sites" twice; there are three. The inventory above exists so the next person reads the set rather than re-deriving it.
- Assuming the recovery path recovers. It overwrites the row, so omitting the field there undoes the fix after a repair.
- Reading a green unit suite as a green corpus. The three residuals above are live-only by construction.
Related
#16016 (comment/reply completeness) · PR #16015 · #16002 / #16010 (facet isolation) · #15154 (Discussion/reply reconciliation semantics) · #15972 (pipeline alarm) · #15977.
Live latest-open sweep at 2026-07-26T15:57Z; re-verified against #16016 on split at 18:12Z. No duplicate.
Authored by Ada (@neo-opus-ada, Claude Opus 5, Claude Code).
Context
The discussion sync fails with
Resource limits for this query exceededand has re-paged the entire discussion history on every scheduled run. Surfaced from a live localsyncGithubWorkflow, operator-reported.The Problem
The delta cutoff has never engaged. It is computed from
updatedAton cached entries:const cachedDiscDates = Object.values(metadata.discussions || {}) .map(d => Date.parse(d.updatedAt)).filter(t => !isNaN(t)); const sinceCutoff = (metadata.lastSync == null || cachedDiscDates.length === 0) ? 0 : …That field was never written.
Date.parse(undefined)→NaN→ filtered out → the date list is empty →sinceCutoffis always0→ theUPDATED_AT-descending early break can never fire. So every run re-pages all ~210 discussions and pays full GraphQL cost for a corpus that has not changed.Measured in live
resources/content/.sync-metadata.json:updatedAtThe issue syncer has always persisted it. This is the discussion side catching up.
The cost, measured rather than asserted.
rateLimit(dryRun:true)on the real query: 15 points per 30 discussions, so a 210-discussion traversal is ~105 points, hourly, indefinitely, against a 5,000/hour ceiling that the corpus exists to protect. The per-query cost ceiling is the symptom; this is why it is reached at all.The Architectural Reality
Three row producers, not one
The field has to be emitted by every writer of a cache row, and there are three:
DiscussionSyncer:569DiscussionSyncer:675MetadataManager:170The recovery one is the dangerous omission: a repair pass that lowers the cutoff, and zeroes it once enough rows lose the field, reintroduces the exact defect it recovered from.
The matched pair — persisting the field alone is WORSE than the bug
The bulk repopulation also did
metadata.discussions = {}and rebuilt only from what the run fetched. That is harmless only because the zero cutoff makes the fetch the whole corpus.Make the delta work without converting that replace to a merge, and every entry it skips loses its
pathandcontentHash, misses the unchanged-content shortcut, and is rewritten on every subsequent run — a permanently non-empty diff in a tracked generated corpus, which would look like a successful fix. The two changes are one change.A job the wipe was doing silently
Denylist containment relied on the wholesale reset to drop a quarantined discussion's metadata row. So the wipe served two unrelated purposes and only one was documented. Containment must clear file, index entry and metadata row explicitly, independently of how the cache is rebuilt.
The Fix
updatedAt.metadata.discussionsis merged, never replaced — required in the same change as (1).Acceptance Criteria
Delivered — each with a mutation-discriminating witness:
updatedAt, asserted against the written file. RED when the prune omits it.updatedAtsurvives the force-refetch overwrite, and the LIVE value replaces a stale cached one. RED when the recovery writer omits it.metadata.discussions— untouched entries keeppathandcontentHash. RED when the wholesale wipe is restored.Live-only residuals —
L3, unobtainable from any unmerged head:sinceCutoffis non-zero on a real run and the paging loop breaks early. Needs a populated post-merge cache; a fixture can only prove the field is readable, not that the live corpus yields a usable high-water mark.rateLimitand recorded beside the ~105-point full-traversal figure.Evidence: achieved
L1; required for closureL3. The code path is proven; the corpus behaviour is not, and this ticket does not close until it is.Out of Scope
#16016. No code path fetches past 50 comments / 20 replies, so a long thread's 51st comment has never been in the corpus and the recovery path re-truncates it. That is data loss, a different failure mode from fetch cost, and it was mis-filed here.#16002/#16010— facet isolation and within-facet resume.#15972,#16007.Avoided Traps
updatedAtwithout the merge. Trades a loud failure for permanent silent churn in a tracked generated corpus — strictly worse, and it would look like a fix.Related
#16016(comment/reply completeness) · PR#16015·#16002/#16010(facet isolation) ·#15154(Discussion/reply reconciliation semantics) ·#15972(pipeline alarm) ·#15977.Live latest-open sweep at 2026-07-26T15:57Z; re-verified against
#16016on split at 18:12Z. No duplicate.Authored by Ada (@neo-opus-ada, Claude Opus 5, Claude Code).