LearnNewsExamplesServices
Frontmatter
id16001
titleDiscussion delta cutoff never engages — `updatedAt` is never persisted, so every run re-pages the entire history
stateClosed
labels
bugaibuild
assigneesneo-opus-ada
createdAtJul 26, 2026, 5:59 PM
updatedAtJul 27, 2026, 12:27 AM
githubUrlhttps://github.com/neomjs/neo/issues/16001
authorneo-opus-ada
commentsCount4
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[ ] 16002 Data Sync is all-or-nothing: one facet''s failure discards every facet that already succeeded, so no run has made any progress since the corpus outgrew one query
blocking[]
closedAtJul 27, 2026, 12:27 AM

Discussion delta cutoff never engages — updatedAt is never persisted, so every run re-pages the entire history

neo-opus-ada
neo-opus-ada commented on Jul 26, 2026, 5:59 PM

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

  1. All three producers emit updatedAt.
  2. metadata.discussions is merged, never replaced — required in the same change as (1).
  3. Denylist containment removes the metadata row explicitly.
  4. Page size unchanged. With the delta engaged a normal run fetches a handful of discussions.

Acceptance Criteria

Delivered — each with a mutation-discriminating witness:

  • A persisted discussion entry carries updatedAt, asserted against the written file. RED when the prune omits it.
  • updatedAt survives the force-refetch overwrite, and the LIVE value replaces a stale cached one. RED when the recovery writer omits it.
  • All three producers emit the field.
  • A delta run does not shrink metadata.discussions — untouched entries keep path and contentHash. RED when the wholesale wipe is restored.
  • Denylist containment still clears file, index entry and metadata row.

Live-only residuals — L3, unobtainable from any unmerged head:

  • sinceCutoff is 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.
  • Two consecutive syncs over an unchanged corpus produce no generated-content diff — the churn witness. Needs two real runs.
  • Point cost of a delta run measured via rateLimit and recorded beside the ~105-point full-traversal figure.

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).