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
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).
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#16007was filed under.#16002carries the analysis and stays open for its within-facet half (AC3/AC4/AC7):syncDiscussionsaccumulates 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 (allDiscussionsfeeds#planBuckets, the denylist union and inventory-based planning) and is separable from this leaf.The Problem
SyncService.emitGeneratedContentAndDerivewas a bare sequentialawaitchain with oneMetadataManager.saveat the end. One facet throwing therefore did both of these at once:PullRequestSyncer.syncPullRequestswas 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
#runFacetthat 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
syncPullRequestscalled. RED against the sequential chain.#runFacetthat re-throws, which reproduces the pre-fix chain. Per witness, not per suite —mode: 'serial'skips the remainder after the first failure, which reads as "passed" and makes suite-level mutation evidence inconclusive.Out of Scope
#16002, which stays open for them.#16001(discussion query cost) — the current trigger, deliberately left live so isolation is provable while a symptom exists.19087298— operator-held; theGH013root cause of#15972.Avoided Traps
#16002's ACs to fit the diff instead of filing this leaf.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).