LearnNewsExamplesServices
Frontmatter
id16010
titleSync facet isolation: one facet''s failure must not discard the facets before it or skip the facets after it
stateClosed
labels
bugaibuild
assigneesneo-opus-ada
createdAtJul 26, 2026, 7:17 PM
updatedAtJul 26, 2026, 11:14 PM
githubUrlhttps://github.com/neomjs/neo/issues/16010
authorneo-opus-ada
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 26, 2026, 11:14 PM

Sync facet isolation: one facet's failure must not discard the facets before it or skip the facets after it

neo-opus-ada
neo-opus-ada commented on Jul 26, 2026, 7:17 PM

Context

Narrow close-target leaf for the between-facet half of #16002. Filed rather than narrowing #16002's own AC set, because editing a ticket's acceptance criteria to match a diff is the laundering version of a close-target fix — the discipline #16007 was filed under.

#16002 carries the analysis and stays open for its within-facet half (AC3/AC4/AC7): syncDiscussions accumulates every page into an in-memory array and writes nothing until the loop ends, so page N failing still discards pages 1…N-1. That is a real restructure of a load-bearing syncer (allDiscussions feeds #planBuckets, the denylist union and inventory-based planning) and is separable from this leaf.

The Problem

SyncService.emitGeneratedContentAndDerive was a bare sequential await chain with one MetadataManager.save at the end. One facet throwing therefore did both of these at once:

  • discarded every facet that had already succeeded — releases, the issue reconcile, the issue push and pull all completed and were thrown away unwritten, because the only save came after everything;
  • skipped every facet after it — a discussions failure meant PullRequestSyncer.syncPullRequests was never called.

So two stale content facets were one fault, not two. And because the current trigger is deterministic, the result was not "slowly falling behind" — it was a corpus frozen indefinitely while every individual facet's own code worked correctly.

The Fix

Each facet runs through a private #runFacet that saves metadata the moment that facet completes and rolls back only that facet's own metadata slices when it does not.

Rollback is per-slice rather than whole-object because facets mutate a shared accumulator in place: without restoring its slices, a facet that throws after mutating — which is exactly what the pull-integrity verdict does — would still have its partial work persisted by the next facet's save.

IssueSyncer.pullFromGitHub's fresh metadata object is merged into the accumulator instead of replacing it, which deletes the hand-maintained carry-over that existed only because the save happened once at the end. Net simplification.

The integrity verdict is preserved and narrowed: a pull corpus measured broken still does not advance, and delivery is still never reached, because the aggregate verdict throws before the auto-push. A generated commit therefore still requires every facet clean.

Acceptance Criteria

  • An injected discussions failure leaves syncPullRequests called. RED against the sequential chain.
  • A failing facet advances nothing — driven by a facet that mutates the accumulator and then throws — while facets that succeeded are persisted, and the failed facet's previous high-water mark survives.
  • The aggregate verdict names every facet that did not advance and the run exits non-zero. A partial run must never report as a clean one.
  • An integrity abort withholds the pull facet only, asserted through the per-facet accounting rather than through rollback state alone — rollback state also holds under a whole-run abort, so asserting it alone witnesses the wrong property.
  • Every witness verified RED against a #runFacet that re-throws, which reproduces the pre-fix chain. Per witness, not per suitemode: 'serial' skips the remainder after the first failure, which reads as "passed" and makes suite-level mutation evidence inconclusive.

Out of Scope

  • Within-facet page-level resume and multi-run convergence#16002, which stays open for them.
  • #16001 (discussion query cost) — the current trigger, deliberately left live so isolation is provable while a symptom exists.
  • Ruleset 19087298 — operator-held; the GH013 root cause of #15972.
  • Parallelising facets. Isolation is not concurrency, and mixing them makes failure attribution harder.

Avoided Traps

  • Narrowing #16002's ACs to fit the diff instead of filing this leaf.
  • Editing the three tests that pinned the whole-run abort until they passed. Each was checked for regression-vs-pinning; one turned out to be a real contract I had broken (the empty-object metadata guarantee) and was fixed in the source, not the test.
  • Reading suite-level mutation results as per-test discrimination — see the last AC.
  • Trading total loss for silent partial loss. A partial run that exits 0 is a regression in the other direction.

Related

#16002 (parent analysis; stays open for the within-facet half) · #16001 · #15972 · #16007 / PR #15999.

Live latest-open sweep at 2026-07-26T17:20Z: adjacent set is #16002, #16001, #15977, #15972; none covers the between-facet isolation as a closable leaf.

Authored by Ada (@neo-opus-ada, Claude Opus 5, Claude Code).