Skip to content

Make the oversized-metadata quarantine durable so sync can complete - #105

Merged
tabmail-kmyi merged 13 commits into
mainfrom
agent/oversized-stopgap
Sep 2, 2026
Merged

Make the oversized-metadata quarantine durable so sync can complete#105
tabmail-kmyi merged 13 commits into
mainfrom
agent/oversized-stopgap

Conversation

@tabmail-kmyi

Copy link
Copy Markdown
Contributor

Closes #104. Companion to #74, which stays open as the tracker for the real (upstream parser) fix — do not close #74 on this.

This does not make an oversized body fetchable. It stops the app paying for a fetch that cannot succeed, and stops it asking the user to wait for one.

The bug

handlePayloadTooLarge quarantines an oversized message in oversizedDeferredThisSession, an in-memory, process-lifetime set. Every launch rebuilds it empty, so every affected message is offered to the body queues again, fails again, and is quarantined again. Each failure is expensive: withFolderConnection classifies PayloadTooLargeError as unhealthy and releases the connection, so the retry pays a full TCP + TLS + LOGIN + SELECT.

Consequences: the body is never indexed; BackfillProgress.pendingBodyCount never reaches 0, so the sync banner never clears and the progress bar parks short of 100%; and opening the message spends a connection to land on "Unable to load message", with a 2s poll repeating it.

Correcting the premise the old comments carried

Several comments described the overflow as size-deterministic per binary. It is not — the parser's bound is on unread aggregate bytes measured after the decode loop stops, so it depends on wire fragmentation. The same message can overflow on a lossy link and parse fine on WiFi.

So an overflow is an observation about one wire attempt, never a verdict that the body is unfetchable, and nothing here treats it as one.

The change

A durable messageHeader.bodyMetadataOversized flag.

  • v88_addBodyMetadataOversized adds only the column. The supporting index messageHeader_bodyRepopulateV2 is built off the blocking launch path by SyncEngine.deferredIndexes, per ADR-IOS-029's 2026-08-05 amendment — its absence degrades performance and nothing else, which is that ADR's own eligibility test, and v83_markAllAsReadUnreadSweepIndex is the precedent (its body is intentionally empty for exactly this reason).
  • One writer symbol, four callers. Every path that observes an overflow records it through BodyFetchProcessor.markBodyMetadataOversized: the singleton branch of handlePayloadTooLarge on both queues, BodyFetchProcessor.fetch's PayloadTooLargeError branch, and the inbox snippet loader's network tier. That last one matters — it calls the same provider.fetchMessage the queues do, and on a scrolling user it is frequently the first path to reach a deep-history message, since backfill admission is date DESC. A tier that observed an overflow and only remembered it in process memory would make the flagged population "whatever a background queue happened to reach first", which is precisely what this column must not be. A multi-item overflow still isolates rather than flagging: the batch error does not say which item was too large.
  • All four marks go through ONE serialized write chain, shared with the UIDVALIDITY reset's clear, so a mark and a clear can never commit out of order. Without that, a mark enqueued just before a folder turnover could land after the reset's clear and the resync, and quarantine a fresh-epoch row that reused the UID — passing both guards below, because that row genuinely lives at that address and genuinely has no body yet.
  • The writer carries two guards, both load-bearing. AND bodyComplete = 0 keeps a completed row from being handed a stale flag. AND id = accountId || ':' || folderPath || ':' || messageId refuses to write inside an optimistic-move window — optimisticMoveToFolder rewrites a row's columns to the destination while its primary key still encodes the source folder and UID, so bytes fetched at the columns' address in that window belong to a different message, and an overflow observed there is not evidence about the row its key names.
  • Eight read-side initiators, one predicate, one authoritative gate. Every path that can start a fetch for a flagged row is gated on the single symbol MessageHeader.isBodyQuarantined (bodyMetadataOversized && !bodyComplete) or, for the SQL half, on the single hoisted Active/BackfillBodyQueue.admissionSQL:
# initiator half role
1–4 repopulateFromDatabase and repopulateOnDrain, on each queue SQL the guard
5 AccountManagerFetch.fetchBody — the funnel every on-demand fetch goes through predicate the guard
6 MessageDetailViewModel.loadBody (the user open) predicate UI state
7 MessageDetailViewModel.startBodyPoll — the 2s body poll predicate keep-polling decision
8 InboxViewModel.loadSnippetBatch tier 2 predicate skip + record

Row 5 is the authoritative one: the network gate lives at the funnel, not only in the callers, so a future caller cannot open a hole by forgetting it. Rows 6–8 are not the guard — they decide which UI state to show and whether to keep polling. The poll and the snippet loader are the two initiators this change adds.

  • And one state the flag structurally cannot cover. Both bodyComplete terms are deliberate: the writer's AND bodyComplete = 0 stops a completed row acquiring a stale flag, and isBodyQuarantined's && !bodyComplete is the eviction fail-safe. Together they make one row invisible at every site — fetched once, messageBody later evicted by BodyAssetMaintenance (which leaves bodyComplete = 1 by design), re-fetch now overflows. Nothing records it; nothing gates on it; it polls forever. BodyFetchRefusal gives the funnel's four refusal classes an identity, and endsPolling is what loadBody and the poll's catch consult before continuing. It also collapses three hand-copied user-facing strings and four bare NSError codes into one place. Both mattered: loadBody's cancelled-read exits all call startBodyPoll(); return before its quarantine branch, so a flagged row was retried every 2s indefinitely, each attempt paying a full TCP + TLS + LOGIN + SELECT; and reloadMessages clears snippetFailed and re-queues the visible window, so tier 2 re-attempted the same fetchMessage that overflowed on every reload. The poll reads the flag fresh from the database each tick, because a background queue can flag the row while the poll is running.
  • Nothing is classified, terminalized or deleted. bodyComplete and bodyEmptyConfirmed stay 0, emptyFetchCount and missFetchCount are untouched, and the header stays FTS-indexed — the message is still findable by subject and sender.

Two product decisions, made deliberately

Both reverse a non-change an earlier revision of this work had built and defended; the superseded reasoning is kept in the source and tests rather than deleted.

The sync banner is allowed to complete. Backfill progress counts a flagged row as settled, so pendingBodyCount can reach 0. A banner that can never clear, over work the build cannot perform, is worse than rounding an unfetchable message up to done. The indexed numerator moves with pending, or the bar contradicts the check.

Opening a flagged message reports failure immediately. loadBody presents the same state a failed fetch leaves behind — error shown, no spinner, header rendered — with no wire attempt and no poll. Pull-to-refresh is deliberately unchanged and still performs a genuine fetch, which is what keeps the flag an observation rather than a verdict.

Releases

  • a UIDVALIDITY reset for the folder (the address no longer names the same message);
  • any successful body write — all four of them. A written body is positive evidence refuting the observation, and this is load-bearing: BodyAssetMaintenance evicts a messageBody row while deliberately leaving bodyComplete = 1, and the detail view's cache-miss fetch is the only recovery. A stale flag would delete that recovery and brick a message already fetched once. The open path carries && !msg.bodyComplete as the fail-safe for any residual;
  • Smart Reindex — its statement needs the flag in both the SET and the WHERE, or it silently skips the rows the gesture was invoked for;
  • pull-to-refresh on the open message;
  • and, when the raised parser bound ships, one statement in that migration: UPDATE messageHeader SET bodyMetadataOversized = 0 WHERE bodyMetadataOversized = 1. Exact by construction, because every row carrying the flag was written by this code.

That last point is why this earns a dedicated column rather than reusing an existing state: a reused value cannot name its own population.

Verification

Full suite green on a dedicated simulator and a dedicated derived-data directory, both created for this branch and deleted afterwards: 9,404 tests in 1,260 suites passed, one pre-existing known issue (SyncFolderEpochPersistenceTests, unchanged by this branch), no test-host restarts, and no actionable compiler warnings — only the three documented-benign appintentsmetadataprocessor lines (one per target that runs the processor).

The run is corroborated as complete rather than truncated: a static census of @Test declarations in TabMailTests/ returns 9,404, exactly the number executed. The same census on the base commit returns 9,337, so this branch adds 67 tests net. (The census must be anchored — ^\s*@Test — because eight doc comments in TabMailTests/ mention @Test in prose and an unanchored count reads 9,410, an eight-test phantom surplus. No test carries a .disabled trait, so the identity is exact rather than a coincidence.) A truncated run cannot match its own census, which is the check that matters here — an earlier run in this work reported a plausible-looking 236 tests because the test host was crash-restarting on a polluted simulator container.

Red-first proved by focused mutation for every new invariant — invert the fix and the test that pins it fails while its control stays green:

mutation test that goes red
drop !msg.bodyComplete from the open path evicted-row recovery
drop bodyMetadataOversized = 0 from the flushBatch success write success retracts the observation
narrow resetCrawlState's WHERE to bodyEmptyConfirmed = 1 Smart Reindex releases the quarantine
put the CREATE INDEX back in v88 migration does not build the index
drop the conjunct from either repopulateFromDatabase that queue's relaunch test
flag on a stale generation generation guard
drop await previous?.value from enqueueDurableWrite durable write ordering
drop the re-minted-key guard from markBodyMetadataOversized mid-move misattribution
delete the funnel's quarantine block the funnel's refusal
stop the snippet loader recording what it observed snippet-loader durability (control: non-overflow failure)
drop bodyMetadataOversized = 0 from the NSE batch flip NSE body clears the observation
drop , bodyMetadataOversized = 0 from oneTimeBodyCompleteRestore's heal the restore releases the quarantine on healed rows only
if BodyFetchRefusal.endsPolling(error)if false in the poll's catch a running poll ends on an overflow the durable flag cannot see
drop the bodyMetadataOversized conjunct from backfill progress a quarantined row lets an account reach Sync Complete
drop AND m.bodyEmptyConfirmed = 0 from the diagnostics predicate the bodyless buckets stay an exact partition
drop AND folderPath = ? from the durable clear the clear releases one folder, not the account
drop AND accountId = ? from the durable clear …and not the device
the funnel's fresh DB re-read → the caller's in-memory copy the funnel gates on the fresh row
add || ns.code == retryable to endsPolling the poll survives a transient refusal

Every mutation ran with a control test in the same run that had to stay green, so a mutation that simply broke the suite cannot be read as a red-first proof; and each mutated file was restored by hash against a pre-mutation backup, not by git checkout.

⚠️ That mutation run also caught one of these tests being vacuous, which is the reason it was worth running rather than asserting. Removing AND m.bodyMetadataOversized = 0 from the diagnostics' pending predicate left the bucket-partition test GREEN: its single fixture row had emptyFetchCount = 2 and failed emptyFetchCount = 0 either way, so the mutation was invisible to it. It now seeds two quarantined rows — one with strikes, one without — because the four buckets subtract the quarantine from two different siblings and one fixture cannot exercise both.

Six further hand-copied admission predicates in the test suite now run the production Active/BackfillBodyQueue.admissionSQL instead. Three of them carry never-drop claims — "a row that satisfies this is one the body fetch WILL pick up" — and had silently stopped being that predicate the moment production gained AND bodyMetadataOversized = 0.

Tests drive production symbols rather than replicas: the relaunch tests call the real repopulateFromDatabase(), Smart Reindex calls the real SyncEngine.resetCrawlState(), the success-clears test calls the real BodyFetchProcessor.flushBatch. The query-plan gate asserts all five equality columns in the seek with no temp B-tree, against the deferred index, with a negative control.

What the audit rounds changed

Ten rounds of four-angle review (architecture, correctness, robustness-security, test coverage) ran
against this branch, each angle on a fresh-context specialist in its own detached reviewer worktree.
The final round carried architecture, correctness and robustness-security forward as clean on a
machine-checked proof that the intervening source diff was comment-only (every added and removed
line in all six touched source files matches ^\s*(///|//)), and re-ran test coverage, whose
substance had changed. The code changes they produced, and the claims they retracted:

  • The clear-side SQL is now one symbol too. The mark side already funnelled through
    BodyFetchProcessor.markBodyMetadataOversized; the clear was hand-copied into both queues'
    clearOversizedDurably. It is now BodyFetchProcessor.clearBodyMetadataOversized, and both
    queues call it. The comment that justified the duplication claimed a shared helper was
    impossible — that was false (Mutex<Task<Void, Never>?> is Sendable and withLock is
    synchronous), and it is replaced by the two real reasons the write chains stay per-queue:
    per-instance isolation, because suites construct their own ActiveBodyQueue() /
    BackfillBodyQueue() against temporary pools, and different priority tiers (syncPool vs
    backgroundPool).
  • "All four marks share this one serialized chain" was too strong. BackfillBodyQueue's
    handlePayloadTooLarge marks through Backfill's chain, not Active's. The invariant that
    actually holds — and the one the ordering depends on — is that every mark shares a serialized
    chain with the clear issued on that same chain, and the UIDVALIDITY reset clears on both.
  • The "one remaining bodyComplete = 1 writer" absolute now carries its negative case.
    Migrations v31_addHasBodyInFTS and v57_repairOptimisticSentBodyComplete also write that
    column; they are excluded because they run before v88 and are frozen, not because they do not
    exist.
  • The release bound is first-party, not upstream. IMAPFetchMapping.responseBufferLimit
    (4 MiB) is ours — upstream PR #179 made it a constructor parameter — so raising it needs no
    upstream work. Several comments had described it as an upstream constant.
  • The refusal strings are a DEBUG surface. MessageCardView renders viewModel.error only
    under DebugModeManager.isLoggingEnabled(); a release user sees the generic
    "Unable to load message. Pull to retry." The refusal texts are documented as such rather than as
    user-facing copy, and ProviderError.networkError's "Network error: " prefix is documented as
    inherited and deliberately not unwrapped.
  • The two body queues no longer carry a copy of the durable-write chain.
    BodyFetchProcessor.DurableWriteChain replaces 63 byte-identical lines on each queue,
    which had sat under an "edit both copies or neither" comment over an ordering the file
    itself labels a correctness requirement. Each queue still holds its own INSTANCE — that
    is load-bearing for test isolation and for the two write-priority tiers — but no longer
    its own copy, and the database pool became a call parameter, which turns the
    eager-resolution rule from a comment into a signature.
  • The ~90-line accepted-limitations enumeration was also duplicated verbatim on both
    queues under the same instruction. It lives once now, on the single writer both call.
  • BodyFetchRefusal.addressInFlight is gone. It carried a byte-identical copy of
    ProviderError.addressPendingMove's user-facing sentence, while fetchAttachment — in
    the same file — already threw the typed case that ComposeView matches by TYPE. The
    funnel throws it too now. Behaviour is unchanged at both fetchBody call sites, and
    endsPolling's exclusion of the mid-move refusal becomes structural rather than a
    listed omission.
  • Four enumerating sentences were replaced by the properties they were caching. Each
    had been correct when written and had gone stale as the branch grew: a census count of
    "three" that returned four, "four surfaces" that omitted two ftsIndexed consumers,
    "five consumers, three of them code" that omitted the authoritative funnel, and a
    self-heal comment still promising a next dispatch that the quarantine had removed.
  • A status claim in the source contradicted the register this branch adds. Three sites headed
    the accepted-limitations enumeration "owner-blessed" while IOS-BODY-006 says the opposite —
    filed open, NOT accepted. That gap is the whole failure mode the register exists to prevent:
    a reader greps the source, reads "owner-blessed", and stops asking the question. The headers now
    name the register and state that exactly two items carry an owner decision (let "Sync
    Complete" fire; fail fast when an affected message is opened — both 2026-09-01), and that the
    other five do not. The register's own blanket "none has the owner's blessing yet" now exempts
    the two that do.
  • Three records a diff cannot show were added to the migration banner. The EXPLAIN QUERY PLAN
    figures now state the statistics regime they were measured under, which IOS-PERF-012 requires
    and which decides whether the numbers mean anything; an earlier revision on this branch built
    the deferred index inside v88 before it moved to deferredIndexes, so a dev database that ran
    the old body already carries the index and converges with a fresh install only because
    createDeferredIndexes is IF NOT EXISTS; and the migration number collides with the sibling
    branch, which breaks no migration but does break this file's own equal-counts self-check after a
    merge.
  • The funnel's typed refusal had no producer-side test. The classification test constructs the
    error by hand and the predicate test never runs the funnel, so reverting the throw to the
    pre-change wrapped NSError left the entire suite green while the refusal started classifying as
    a connection error — a wasted retry and a "check your connection" message for something that has
    nothing to do with the network. Both halves green, contract dead. A mutation reproducing exactly
    that revert now turns the new test red while its control stays green in the same run.
  • Two conjuncts were satisfied vacuously, in the two requests this branch introduces, and both
    are now pinned. Every fixture in the partition suite seeded one account, so accountId could be
    deleted from either request with the whole suite green; a second account is now seeded with one
    pending-shaped and one settled-shaped row, and two mutations drop the conjunct from each request
    in turn. The second was headerComplete, which pendingBodyRequest carries and
    bodySettledRequest deliberately omits — every fixture that measured either request set it by
    construction, so both of the opposite edits left all 9,402 tests green. That one was proved
    against the full suite rather than a targeted subset, because the claim under test was not
    "does my new test catch it" but "does anything else": each mutation reddened exactly one test of
    9,403, the new one. The rows that distinguish it are reachable — NSEDataBridge stages headers
    with headerComplete = false and flips the flag in a separate statement, so an extension wake
    terminated in between leaves one behind. Dropping the term from pending re-creates the
    never-clearing banner and pinned wake lock this branch exists to remove; adding it to settled
    parks "N / M indexed" permanently below its denominator.
  • Two funnel tests explained their own safety with the wrong mechanism. They said no provider is
    registered, so the call cannot reach the wire — backwards, since the quarantine gate sits before
    the provider block and an absent provider is what causes connectAccount to be called. What
    actually keeps them offline is that the fixture's account has no imapHost, so
    createIMAPProvider throws before any provider is constructed, registered in the process-wide
    singleton, or asked to connect. The tests were safe for an unstated reason that one plausible
    edit — adding host and port to that fixture — would have removed silently, turning them into live
    connection attempts from the unit suite. Both comments now name the real guard and both fixtures
    assert it.
  • The funnel's outcome→refusal-class mapping was reachable but unpinned, and it is the headline
    behaviour.
    fetchBody's tail turns a processor outcome into a refusal — .payloadTooLarge to a
    terminal class, .retry to a retryable one — and both loadBody and the 2s poll branch on exactly
    that distinction. Both halves were tested and the join was not: the classifier test constructs
    every refusal by hand, every MessageDetailViewModel test injects _fetchBodyOverride, and all
    six other test call sites of fetchBody exit before the tail. Swapping the two arms left all
    9,403 tests green
    while restoring the original defect in full — an oversized body would classify
    as retryable, the poll would start, and the row would be re-fetched every two seconds forever. The
    poll's own quarantine gate cannot save it in the eviction case, where the row reads
    bodyComplete = 1. A new test now drives the funnel to completion against a registered mock
    provider; under the swap both of its legs go red. It asserts the classification, never the
    integer code, since pinning -1/-2 would be a mechanism-pinning test.
  • Two residual properties are registered rather than mechanised. See below.

Accepted residuals — IOS-BODY-006, filed open

Registered in the known-issues register (post-freeze amendment channel) and documented at the
mechanisms themselves, not only in the register:

  1. The flag is a one-strike latch on a non-deterministic signal. One overflow on one wire
    attempt quarantines the row until something positively releases it; there is no strike counter
    and no automatic expiry. Because the parser bound is on unread aggregate bytes measured after
    the decode loop stops, a message that overflows on a lossy link can parse fine on WiFi — so a
    single unlucky attempt can quarantine a message this build could have fetched. The recovery is
    one ordinary user gesture (pull-to-refresh on the open message, or Smart Reindex), which is why
    this is filed as a residual rather than built around.
  2. Two writers reach the flag outside the serialized chain. SyncEngine.resetCrawlState
    (Smart Reindex) writes through AppDatabase.backgroundPool, and markOversizedDurably's
    non-queue callers are ordered against the reset's clear only by that same chain. A mark
    dispatched just before a Smart Reindex can therefore commit after its clear, leaving a row
    quarantined that the gesture was invoked to release. It fails closed — the row is retryable
    by the same gesture, repeated — per THE MANTRA, and the window is one dispatch wide.

Both are filed with class open, not accepted: they need the owner's decision, and this PR does
not assert one.

The full decision being asked for. Seven limitations are enumerated on
BodyFetchProcessor.markBodyMetadataOversized. Two already carry an owner decision, both dated
2026-09-01 and both reversals of an earlier stance in this branch: let "Sync Complete" fire on an
account that still holds an unfetchable body, and fail fast when such a message is opened rather
than spending a doomed wire attempt. The other five do not, and are what this PR puts in front
of you:

  1. the body is unsearchable by content until IMAPFetchMapping.responseBufferLimit is raised
    (the header stays FTS-indexed, so the message is still findable by subject and sender);
  2. the FTS self-heal deliberately does not carry the flag, because it re-indexes headers and
    this row's header is healthy;
  3. affected rows show no snippet preview in the inbox;
  4. expanding a collapsed thread bubble for a flagged message yields nothing — no body, no error, no
    wire attempt — with recovery by opening it as the focused message;
  5. the one-strike latch itself (item 1 above), which is the one I would most like a decision on.

Until you rule, the source says open and not accepted in every place it mentions them, and an
earlier revision of this branch that called the set "owner-blessed" was corrected by the audit.

Merge order

⚠️ This branch and #103 (draft) both register a migration named v88 — here v88_addBodyMetadataOversized, there v88_addBodyIndexingFailureReason. They are merge-exclusive by construction, not conflicting in substance. This one merges first; #103 renumbers to v89 when it resumes. Verified: #103 does not yet carry that prerequisite in its body or comments, so it is being noted there separately.

One unrelated commit rides along

Wait for the fake IMAP transport to settle before asserting no live session fixes a pre-existing race in the test harness, not in the app. assertIMAPTeardown read FakeIMAPServer.liveSessionCount() the instant provider.disconnect() returned, but disconnect returns when the client closes its socket while the server drops the fd on its own thread when it observes that close. Instrumented on a failing run: the count read 1, healed to 0 after 50ms, and abandonedSessionCount() — the monotonic oracle that never heals — was 0, so nothing had actually been abandoned. The assertion now waits for the transport to settle, bounded at 2s, and still fails on a session genuinely left live.

A message whose IMAP metadata FETCH overflows the response parser's buffer
was quarantined only in `oversizedDeferredThisSession`, an in-memory Set that
is rebuilt empty on every launch. Every launch therefore re-fetched every
oversized message and failed again — and each failure is expensive rather than
merely wasted, because `withFolderConnection` classifies `PayloadTooLargeError`
as unhealthy, tears the connection down, and makes the next attempt pay a full
TCP + TLS + LOGIN + SELECT.

Adds `messageHeader.bodyMetadataOversized`, written beside the existing
in-memory insert in both queues' `handlePayloadTooLarge` singleton branch —
the one place that is already attributed (`items.count == 1`) and already
generation-guarded. The four body-fetch admission queries exclude it.

The flag records an OBSERVATION about one wire attempt, not a verdict. The
parser bound is on unread aggregate bytes measured after the decode loop
stops, so it is fragmentation-dependent: the same message can overflow on a
lossy link and parse fine on WiFi. Six comments that described the failure as
"size-deterministic per binary" are corrected. Nothing is marked complete or
empty, and the row stays fetchable on the on-demand path.

Deliberate non-changes, each pinned by a test so a later cleanup cannot
quietly undo them:
  - `BackfillProgress.pendingBodyCount` still counts the row, so the
    "Sync Complete" banner stays honest about a genuinely missing body.
  - `SyncEngineFTS.selfHealBackfillFTSMembership` still sees it, so the
    healthy header stays searchable by subject and sender.

Index: the admission queries gain a fifth equality predicate, so
`messageHeader_bodyRepopulate` (v40) is extended by one column into
`messageHeader_bodyRepopulateV2`, keeping `date` last so one index serves the
seek and the ORDER BY. Measured with EXPLAIN QUERY PLAN: all five equality
columns enter the seek with no temp B-tree. A partial index was tried first
and rejected — the planner never chose it over v40's, so it would have been
pure write amplification. Per ADR-IOS-029 the v40 index is not dropped.

The two dispatched writes are serialized behind one chain. `handlePayloadTooLarge`
and `clearOversizedDeferred` are deliberately synchronous, so the durable write
cannot be awaited inline; but mark and clear can be dispatched microseconds
apart by a UIDVALIDITY reset, and if they reordered the clear would run first
and the mark would re-flag a row whose address no longer refers to the same
message.

Releases: a UIDVALIDITY reset and Smart Reindex clear the flag, and the
migration that ships a raised parser bound clears it in one exact statement —
possible only because nothing else writes this column. That replaces the old
"relaunch gives a fresh attempt" release with one targeted retry when the
bound actually changes.

Verification: 9,354 tests, 1,258 suites. Red-first evidence recorded by
inverting the fix — five tests fail without the write, including both
relaunch invariants. Also repairs four migration tests that decoded or
inserted the current model against a deliberately older schema, a latent
fragility that any future non-optional column would have tripped.

Does not make the body fetchable; that needs the upstream parser fix, tracked
separately. Issue #74 stays open.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Owner decision 2026-09-01, reversing two deliberate non-changes in the
oversized-metadata quarantine. A message whose metadata FETCH overflows the
IMAP response parser cannot be fetched by this build; treating that as
outstanding work nags the user forever about something no amount of waiting
fixes.

Backfill progress now counts a flagged row as settled rather than pending, so
pendingBodyCount can reach 0, isFullyComplete becomes true, the sync banner
clears and Fast Sync stops keeping the device awake. The indexed numerator
moves with it, or the progress bar would park one short of 100% beside a green
completion check. Both predicates are now named requests on MessageHeader
(pendingBodyRequest / bodySettledRequest) instead of inline filter chains: a
GRDB chain is invisible to every SQL-text census, and the tests that covered it
were hand-copied replicas, which cannot fail when the predicate changes.

Opening a flagged message now reports the load-failed state immediately -
error set, not loading, no body, header rendered - without a wire attempt and
without starting the 2s poll. The attempt would cost a full TCP+TLS+LOGIN+
SELECT (the overflow marks the folder connection unhealthy) and end in this
same state, and no background path can produce the body because the flag is
what removed it from both queues. Pull-to-refresh is deliberately unchanged and
still performs a genuine fetch: the parser bound is fragmentation-dependent, so
the same message can succeed on a different connection. That retry is what
keeps the flag an observation rather than a verdict.

Verification: full suite 9,359 tests / 1,259 suites green, one pre-declared
known issue, single test-run line. Red-first established by inverting both
changes - 4 tests fail with 7 issues, while the controls (the identical
unflagged fixture still fetches, a durable body outranks the flag, and the
whole wake-lock suite) stay green.

Accepted limitations, recorded at the mechanism in BackfillBodyQueue and on
MessageHeader.bodyMetadataOversized: "Sync Complete" can now fire on an account
that still holds an unfetchable body, and such a message is unopenable without
a pull-to-refresh. Both are the owner's call and revisit when the parser bound
is raised upstream. The superseded reasoning is kept in place in the source,
the tests and the plan rather than deleted, so it is not re-derived later.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Review round 1 found the stop-gap's own regression: nothing ever cleared
`bodyMetadataOversized`, so the flag outlived the observation it recorded.
`BodyAssetMaintenance` evicts a `messageBody` row while deliberately leaving
`bodyComplete = 1`, and the detail view's cache-miss fetch is the only recovery
for that. The open-path short-circuit, keyed on the flag alone, deleted that
recovery: a message this build had already fetched successfully became
permanently unopenable — strictly worse than the bug being fixed. Smart Reindex
had a second face of the same defect, setting `bodyComplete = 0` on a healthy
flagged row.

Outcome:

- All four success writes now clear the flag — `BodyFetchProcessor.flushBatch`
  (body branch and confirmed-empty branch), `NSEDataBridge.flushNSEBatchToFTS`,
  `SyncEngine.applySnippetUpdates`. A written body is positive evidence that
  refutes an overflow observation.
- The mark carries `AND bodyComplete = 0`, so a row that already has a body can
  never acquire the flag; the write is dispatched, so a pull-to-refresh can win
  the race.
- The open path adds `&& !msg.bodyComplete` as the fail-safe for any residual
  flag, keeping eviction's designed recovery reachable.

Second round-1 finding: `v88_addBodyMetadataOversized` built
`messageHeader_bodyRepopulateV2` inside the migration, i.e. on the blocking
launch path, contrary to ADR-IOS-029's 2026-08-05 owner amendment ("only things
that are absolutely necessary and blocking"). The index passes that ADR's own
eligibility test — its absence degrades performance and nothing else — so it
moved to `SyncEngine.deferredIndexes`, following the `v83` precedent whose body
is intentionally empty for the same reason. The migration is now one
`ADD COLUMN`.

Third round-1 finding: three quarantine suites dispatched escaped durable writes
at the process-global `AppDatabase.shared` without `.processGlobalState` and
without draining them, so a write could land in a later suite's swapped
database. They now carry the trait and drain every queue they touch, as does the
UIDVALIDITY-reset companion suite.

Verification — 65 tests in 11 suites green, full suite 9,370 tests in
1,259 suites green, and red-first
proved by focused mutation for each new invariant:

- remove the open path's `!msg.bodyComplete` -> evicted-row recovery test fails
- remove `bodyMetadataOversized = 0` from the flushBatch success write -> the
  success-clears-the-flag test fails
- narrow `resetCrawlState`'s WHERE back to `bodyEmptyConfirmed = 1` -> the Smart
  Reindex test fails
- put the CREATE INDEX back in v88 -> the migration-shape test fails
- drop the conjunct from either `repopulateFromDatabase` -> that queue's
  relaunch test fails
- flag on a stale generation -> the generation-guard test fails
- remove `await previous?.value` from `enqueueDurableWrite` -> the ordering
  tests fail, each against the mutant that delays the write it would otherwise
  be blind to (latency on the mark fails release-after-mark; latency on the
  clear fails mark-after-release)

Tests now drive production symbols rather than replicas: the relaunch tests call
the real `repopulateFromDatabase()`, the Smart Reindex test calls the real
`SyncEngine.resetCrawlState()`, and the success-clears test calls the real
`BodyFetchProcessor.flushBatch`. The Active queue gained mirrors of the three
durable-write tests that previously ran only on Backfill.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
…ession

`assertIMAPTeardown` read `FakeIMAPServer.liveSessionCount()` immediately
after `provider.disconnect()` returned. Disconnect returns once the CLIENT
has closed its socket; the fake server drops the fd from `loggedInFds` on
its own `handleClient` thread, in `closeClientFd`, when it observes that
close. Nothing synchronises the two, so the assertion races the server
thread and a loaded machine loses that race.

Measured during a full-suite run at load average ~140: `liveSessionCount()`
read 1 immediately, healed to 0 after 50ms, and `abandonedSessionCount()`
was 0 — nothing had actually been abandoned, the oracle had simply looked
too early. The failure appeared on six full-suite runs and never once when
the suite was run in isolation, which is the signature of the race rather
than of a leak.

The check now waits for the transport to settle, bounded at 2s: a session
genuinely left live never heals and still fails, and the message reports
how long it waited. This does not weaken the abandoned-session invariant —
`abandonedSessionCount()` is monotonic by construction, never heals, and
remains asserted exactly by the callers that care about it.

Verification: full suite green, 9,381 tests in 1,260 suites, one
pre-existing known issue, no host restarts.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Round-2 audit finding: the stop-gap gated the four background admission
queries and the user-open path, but left two automatic fetch initiators
live, and threw away the observation made by the path that detects the
overflow soonest.

Closed all three:

- `InboxViewModel.loadSnippetBatch` tier 2 calls the same
  `provider.fetchMessage` that overflowed. It is now refused before the
  network and blacklisted for the session. This one mattered most:
  `reloadMessages` clears `snippetFailed` and re-queues the visible
  window, so an ungated row was retried on every reload.
- `MessageDetailViewModel`'s body poll. `loadBody`'s three cancelled-read
  exits all `startBodyPoll(); return` BEFORE its quarantine branch, and
  `refetchBody` restarts the poll whenever a retry produced no body — so
  a flagged row retried every 2s indefinitely, each attempt paying a
  full TCP+TLS+LOGIN+SELECT. The poll now reads the flag fresh (the
  queues can mark a row while it runs), reports the load failure and
  ends.
- `BodyFetchProcessor.fetch`'s `PayloadTooLargeError` branch now records
  the observation, under the same `AND bodyComplete = 0` guard the two
  queues use. A comment there claimed the omission was harmless because
  "that path performs no retry loop"; it does — `loadBody` ends with
  `if messageBody == nil { startBodyPoll() }`. Without this the flagged
  population was "whatever a background queue reached first".

The read-side predicate is now one symbol, `MessageHeader
.isBodyQuarantined`, shared by all three code initiators; the four
admission queries are the SQL half and are now single symbols too
(`Active`/`BackfillBodyQueue.admissionSQL`), replacing two byte-identical
copies per queue plus a test replica.

Also from the round, all verified against source before acting:

- `StuckMessageDiagnostics` reported quarantined rows as "pending",
  i.e. as work still queued. Split into its own bucket, keeping the four
  bodyless buckets an exact partition, and the flag added to the sample
  dump.
- Deleted a vacuous test (`flaggingNeverRetiresTheRow` asserted only
  that its own `UPDATE` left other columns alone) and moved the real
  claim onto the queue-driven tests.
- Three test replicas replaced by the production symbols they copied —
  `admissionSQL`, `backfillFTSSelfHealCandidateSQL`, and the eligibility
  helper, which now drives the real queue's `repopulateFromDatabase()`.
- A leaked body poll in `pullToRefreshStillRetries` (its 2s tick calls
  the live `AccountManager`) is now stopped in `defer`.

Corrected four claims in comments that were false:

- the flag's consumer census (three consumers, now five) and its clear
  census — `rg 'bodyMetadataOversized = 0'` finds only three of the four
  success writes, because `SyncEngine.applySnippetUpdates` writes it as
  a GRDB `updateAll` chain and is invisible to SQL-text search;
- `BackfillProgress`'s "empty/404/oversized bodies confirm-empty" — the
  exact conflation this change removes, since confirming an oversized
  body empty is the data-integrity-rule-1 violation that shipped once;
- `clearOversizedDurably`'s "cannot drift from `MessageIdentity`'s
  parsing" — it can, for a mid-optimistic-move row; the divergence is
  benign in both directions and now says why it is kept;
- the v40 index's "(other queries use it)" — unverified, and not the
  reason; it is the measured fallback plan for these same four queries.

Tests: 13 new, red-first proved in two mutation batches (production
gates; write guards and clear sites), each target test red with its
controls green, restored by file backup and verified byte-identical.
Added a partition property test over the whole disposition table, since
`pendingBodyCount` and the indexed numerator are the same question from
opposite sides and the stop-gap added a third settled disposition.

Verification: full suite green on a dedicated simulator, 9,381 tests in
1,260 suites (9,370 before; 13 added, 2 removed), one pre-existing known
issue, no host restarts, no new compiler warnings.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
…tion

`tabmail-ios` is a public repository, and this doc comment carried a
verbatim quote from a private project conversation, attributed with a
gendered pronoun for a real person. Neither belongs in published source,
and neither carried information the comment needs.

Replaced with a plain statement of the criterion itself — the admission
query must be provably optimal under the new index — which is what the
test actually gates. No test behaviour changes.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Round-3 audit fixes for the oversized-metadata quarantine.

Outcome. The durable flag now has exactly one writer symbol,
`BodyFetchProcessor.markBodyMetadataOversized`, replacing three
hand-copied UPDATE statements that had already begun to diverge. Its
four callers are the singleton branch of `handlePayloadTooLarge` on
both body queues, `BodyFetchProcessor.fetch`, and — new here — the
inbox snippet loader's network tier, which previously observed an
overflow and remembered it only in `snippetFailed`, a set
`reloadMessages` clears on every `.inboxDataDidChange`. Tier 2 calls
the same `provider.fetchMessage` the queues do and, because backfill
admission is `date DESC`, is frequently the first path to reach a
deep-history message; leaving its observations in process memory made
the flagged population "whatever a background queue reached first",
which is the one property this column must not have.

Invariant impact. The writer carries a second guard,
`AND id = accountId || ':' || folderPath || ':' || messageId`. During
an `optimisticMoveToFolder` window a row's columns name the
destination while its primary key still encodes the source folder and
UID, so bytes fetched at the columns' address belong to a different
message; an overflow observed there is not evidence about the row its
key names, and is now refused rather than recorded. The network gate
also moves to the funnel, `AccountManagerFetch.fetchBody`, so a future
caller cannot open a hole by forgetting it; the checks in
`MessageDetailViewModel` and the snippet loader remain but are
demoted, in code and in comment, to deciding UI state and whether to
keep polling.

Also: the four bodyless diagnostic bucket predicates are hoisted to
named constants so the partition can be asserted rather than assumed,
and six test replicas of the queue admission predicate now run the
production `Active`/`BackfillBodyQueue.admissionSQL` — those replicas
stopped being the admission predicate the moment it gained
`AND bodyMetadataOversized = 0`, so assertions claiming to describe
the repopulate query no longer did.

Verification. Full suite on a dedicated simulator and derived-data
directory: 9,388 tests in 1,260 suites passed, one pre-existing known
issue (`SyncFolderEpochPersistenceTests`, unchanged), no test-host
restarts, no actionable compiler warnings. Seven tests added, all
confirmed run by name: mid-move misattribution refusal, snippet-update
flag clear, the bodyless-bucket partition under the quarantined-and-
previously-empty overlap, the funnel's refusal, the snippet loader
recording its observation, its non-overflow control, and the NSE
body-merge clearing a recorded observation.

Limitations. None accepted. This still does not make an oversized body
fetchable; that remains the upstream parser fix.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Round-4 audit fixes. Four angles ran on fresh-context specialists;
architecture came back clean, the other three did not.

Outcome. The stop-gap's headline symptom — a 2s poll paying a full
TCP + TLS + LOGIN + SELECT for as long as the message is open — had a
state left open that no gate could see. Both `bodyComplete` terms are
deliberate: `markBodyMetadataOversized` guards `AND bodyComplete = 0`
so a completed row cannot acquire a stale flag, and
`isBodyQuarantined` carries `&& !bodyComplete` so an evicted-but-
fetched row can still recover. Together they make a row that was
fetched once, whose `messageBody` `BodyAssetMaintenance` later evicted
while leaving `bodyComplete = 1`, and whose re-fetch now overflows,
invisible at every site: nothing records it and nothing gates on it,
so it polled forever. New `BodyFetchRefusal` gives the funnel's four
refusal classes an identity; `loadBody` no longer starts a poll behind
one that nothing will retract, and the poll's own catch ends on one.
Pull-to-refresh is untouched and still reaches the wire.

Invariant impact. All four durable marks now share ONE serialized
write chain with the UIDVALIDITY reset's clear. Previously the two
non-queue writers wrote directly, so a mark enqueued before a folder
turnover could commit after the reset's clear and the resync, and
quarantine a fresh-epoch row that reused the UID — passing both
guards, because that row genuinely lives at that address and has no
body yet. Separately, `enqueueDurableWrite` now captures its pool
before the task rather than resolving `AppDatabase.shared` after the
await; in production that is a no-op, in tests it stops a write that
outlives its suite from clearing a sibling suite's fixture.
`SyncEngineFTS.oneTimeBodyCompleteRestore` now clears the flag in the
same statement that sets `bodyComplete = 1`, which is what makes the
CLEARED enumeration true rather than nearly true.

Hygiene. The one added log line that printed an untruncated header id
(and with it a user-authored folder name) now truncates like every
other; the funnel's entry log, ungated since before this branch, is
debug-gated; `StuckMessageDiagnostics.countForTesting` — the one test
seam that interpolates into SQL — is `#if DEBUG`.

Verification. Full suite on a dedicated simulator and derived-data
directory. A focused mutation run proved red-first for the round-3
invariants and, in doing so, caught one of my own tests being vacuous:
the bodyless-bucket partition stayed GREEN when the pending predicate
lost its quarantine conjunct, because its single fixture row had
`emptyFetchCount = 2` and failed `emptyFetchCount = 0` either way. It
now seeds two quarantined rows, with and without strikes, so both
subtractions are exercised. Six more hand-copied admission predicates
in tests — including three carrying never-drop claims — now run the
production query, having silently stopped being that predicate when it
gained `AND bodyMetadataOversized = 0`.

Limitations. One newly enumerated, in the source beside the mechanism:
expanding a collapsed thread bubble whose message is flagged now
yields nothing rather than a lucky re-roll on different fragmentation.
That function has always swallowed every failure, so the visible
outcome is unchanged; the recovery is to open the message directly,
where pull-to-refresh is exempt at the funnel.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Fifth-round audit corrections to the oversized-metadata quarantine. No change
to the stop-gap's behaviour except the two new release/ordering pins below;
the rest is one deduplication, four retracted claims, and the coverage the
audit found missing.

Code:
- BodyFetchProcessor.clearBodyMetadataOversized is the single clear-side
  writer, matching the single mark-side writer. Both queues' identical
  clearOversizedDurably SQL now calls it. The comment that had justified the
  duplication asserted a shared helper was impossible; that was false, and it
  is replaced by the real reasons the write CHAINS stay per-queue (per-instance
  test isolation against temp pools, and different priority tiers).
- Two stale stacked doc blocks removed from BodyFetchProcessor: a leftover
  description above markBodyMetadataOversized and a self-contradicting one
  above markOversizedDurably.
- The diagnostic MoveTrace print on the fetchBody address-corroboration path
  is now debug-gated.

Retracted or scoped claims, all of which had walked reviewers past the real
shape of the mechanism:
- "All four marks share this one serialized chain" was too strong.
  BackfillBodyQueue marks through Backfill's own chain; the invariant that
  holds is that every mark shares a chain with the clear issued on that SAME
  chain, and the UIDVALIDITY reset clears on both.
- The "one remaining bodyComplete = 1 writer" absolute now carries its
  negative case: v31 and v57 also write that column, excluded because they
  precede v88 and are frozen.
- The release bound is first-party IMAPFetchMapping.responseBufferLimit, not
  an upstream constant, so raising it needs no upstream work.
- The refusal strings are a DEBUG surface: MessageCardView renders the error
  only under DebugModeManager.isLoggingEnabled(), and a release user sees the
  generic retry copy. ProviderError.networkError's prefix is documented as
  inherited and deliberately not unwrapped.
- The poll's fresh-read quarantine gate no longer claims the catch below only
  logs and continues; its two residual reasons are stated instead.

Residuals, registered as IOS-BODY-006 with class open (not accepted) and
documented at the mechanisms themselves:
- The flag is a one-strike latch on a fragmentation-dependent signal, with no
  strike counter and no automatic expiry. Recovery is one user gesture.
- SyncEngine.resetCrawlState and markOversizedDurably's non-queue callers
  write outside the serialized chain, so a mark dispatched just before Smart
  Reindex can commit after its clear. Fails closed, one dispatch wide,
  recoverable by repeating the gesture.

Tests, four new invariants each proved red-first with a control green in the
same run and the mutated file restored by hash against a pre-mutation backup:
- the one-time bodyComplete restore releases the quarantine on the rows it
  heals and only those (two-sided against a skipped cached-HTML row);
- a running poll ends on an overflow the durable flag structurally cannot see,
  with a control proving it survives an ordinary failure on the identical row;
- end to end, a quarantined row lets an account reach Sync Complete while an
  identical unflagged row does not;
- a non-queue mark is ordered against the reset's clear, with a non-vacuity
  control and a settle window.
Plus: the bodyless diagnostic buckets now seed all five buckets rather than
one, which is what makes the partition assertion able to fail; three
PayloadTooLarge retryability tests drain the durable-write chain before
reading; a force-unwrap became try #require per testing rule 9.

Verification: 9,398 tests in 1,260 suites passed, one pre-existing known issue
(SyncFolderEpochPersistenceTests, unchanged by this branch), no test-host
restarts. The static @test census is 9,398, equal to the number executed, so
the run is not truncated. Fresh full compile shows three warnings, all the
documented-benign appintentsmetadataprocessor diagnostic, and nothing else.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Sixth-round audit corrections. Two of the four angles came back clean; these
address the reuse findings from architecture and the coverage gaps from test
coverage.

Deduplication, which is what this commit is mostly for:
- BodyFetchProcessor.DurableWriteChain replaces 63 BYTE-IDENTICAL lines on each
  body queue. The two queues still hold their own INSTANCE — that is
  load-bearing for test isolation (suites construct their own queues against
  temp pools) and for the two write-priority tiers — but they no longer hold
  their own COPY. The block carried an "edit both copies or neither" comment
  over an ordering the file itself labels a correctness requirement, so a
  hardening applied to one queue would have silently left the other with the
  old semantics. The pool is now a parameter rather than a captured property,
  which makes the eager-resolution rule structural instead of a comment.
- The ~90-line accepted-limitations enumeration was duplicated verbatim on both
  queues' markOversizedDurably under the same instruction. It lives once, on
  the single writer both of them call, with a pointer from each queue.
- BodyFetchRefusal.addressInFlight is deleted. It carried a byte-identical copy
  of ProviderError.addressPendingMove's user-facing sentence, and fetchAttachment
  in the same file already threw the typed case that ComposeView matches BY TYPE.
  The funnel now throws it too. Behaviour is unchanged at both fetchBody call
  sites: fetchBodyWithRetry retries only messageNotFound and isConnectionError,
  and neither matches the typed case nor the NSError encoding it replaces. The
  endsPolling exclusion becomes structural rather than a listed omission.

Coverage, each proved red-first with a control green in the same run and the
mutated file restored by hash against a pre-mutation backup:
- The durable clear's (accountId, folderPath) scoping had no test that could go
  red — every fixture in the tree is single-account, single-folder, and the
  three tests whose names promise scoping assert only the in-memory set, which
  is a different predicate. Dropping either conjunct survived all 9,398 tests.
  The new test seeds one bystander per conjunct, so each term is pinned
  separately; two mutations, each reddening exactly its own bystander.
- The funnel's FRESH re-read was unpinned: every funnel test passed a header
  already matching its row, so trusting the caller's copy survived. The caller
  this protects is loadThreadMessageBody, which holds headers of arbitrary age.
- endsPolling's membership was unpinned. Both poll controls throw
  messageNotFound, which is not a networkError and exits at the first guard, so
  widening the code comparison to include `retryable` left every poll test
  green while deleting the poll's entire transient-recovery role. The new test
  walks all six classes, isolating each guard.

Comment corrections, all of them stale enumerations rather than wrong
mechanisms:
- The "census finds only THREE" count was correct when written and went stale
  when round 4 added a fifth clear; it now states the property (exactly one
  clear is invisible to SQL-text search) instead of an integer.
- "Four surfaces move with that decision" omitted the two ftsIndexed arithmetic
  consumers, estimatedSecondsRemaining and updateRate's EMA; it now states the
  property and accepts the small ETA consequence explicitly.
- "Five consumers, three of them code" omitted the funnel — the authoritative
  gate — on the code side and the diagnostics predicates on the SQL side. Both
  integers are gone.
- selfHealBackfillFTSMembership still promised "body queue's next dispatch will
  succeed", which stopped being true for the flagged subset the paragraph below
  it deliberately keeps in scope.
- The snippet loader's overflow classification records that it is the 14th
  hand-copy of that substring test, and that the canonical symbol arrives on the
  branch for #103; a competing local helper is deliberately not minted, because
  that would leave two predicates to reconcile at merge instead of one.

Verification: 9,401 tests in 1,260 suites passed, one pre-existing known issue
(SyncFolderEpochPersistenceTests, unchanged by this branch), no test-host
restarts. Static @test census is 9,401, equal to the number executed. Fresh full
compile: three warnings, all the documented-benign appintentsmetadataprocessor
diagnostic, nothing else.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
… typed refusal

Round-7 audit (four fresh-context Claude specialists on 690b944) returned
architecture, correctness and robustness-security CLEAN with comment-level
findings only, and test-coverage UNCLEAN on two missing tests. This applies all
of them.

The one that matters most is a status claim. Three source sites headed the
accepted-limitations enumeration "owner-blessed", while the register this same
branch adds — IOS-BODY-006 — says the opposite: filed `open`, NOT `accepted`.
That is the exact shape the no-accepted-limitation-without-owner-blessing rule
exists to prevent: a future reader greps the source, reads "owner-blessed", and
stops asking the question the record was filed to raise. The headers now name
the register, state that exactly TWO items carry an owner decision (item 2, let
"Sync Complete" fire; item 4's fail-fast-on-open, both 2026-09-01), and say
plainly that items 1, 3, 5, 6 and 7 do not. Item 4 gained the dated marker it
was missing, and the register's blanket "none has the owner's blessing yet" now
exempts the two that do.

Also swept: a stranded "Edit both copies or neither" instruction that survived
the hoist which deleted the second copy; two pointers (the register and the
KNOWN_ISSUES row) still aiming at both queues' markOversizedDurably instead of
the single writer; a "codes -1/-2/-4 have always been wrapped this way" that is
false for -4, which this branch introduces; and a complement claim on
bodySettledRequest that reads as a lockstep invariant a maintainer would
"restore" — the request is deliberately not scoped to headerComplete, the
asymmetry is inherited, and either edit would move published progress numbers.

AppDatabase gained three records a diff cannot show: the EXPLAIN QUERY PLAN
figures now state the statistics regime they were taken under (empty/no
sqlite_stat1 row, the shipped fresh-install regime; the fresh-statistics side is
unmeasured), as IOS-PERF-012 requires; an earlier in-branch revision built
messageHeader_bodyRepopulateV2 INSIDE v88 and it moved to deferredIndexes at
436c7c3, so dev databases that ran the old body already carry the index and
converge with a fresh install because createDeferredIndexes is IF NOT EXISTS;
and the v88 number collides with PR #103, which breaks no migration but does
break this file's own equal-counts self-check after a merge (21 distinct numbers
vs 22 .immediate lines), so whichever branch lands second must renumber and wipe
every dev DB that ran the old name.

Tests. Two gaps, both proven by mutation before being closed:

  - The funnel's mid-move refusal had no producer-side test. The classification
    test constructs the error by hand and the predicate test never runs the
    funnel, so reverting the throw to the pre-change wrapped NSError left the
    whole suite green while isConnectionError started returning true for it —
    a wasted retry and a "check your connection" message for a refusal that has
    nothing to do with the network. M17 reproduces exactly that revert and the
    new test goes red while the classification test stays green in the same run.
  - The partition test seeded a single account, so the accountId conjunct was
    satisfied vacuously and could be deleted from either request with the whole
    suite green. A second account is now seeded with one pending-shaped and one
    settled-shaped row; M18a and M18b drop the conjunct from each request in
    turn and each goes red on its own bystander.

Verification: full suite green on a fresh compile of the app targets, run on a
dedicated simulator and derivedData directory. Three mutations red-first with
controls green in the same invocation and every restore verified by SHA-256
against a pre-mutation backup rather than by git status, which is invalid in a
tree carrying uncommitted fixes.

Accepted limitations: unchanged, and still awaiting the owner's decision on the
five that have not had one. Nothing here reclassifies IOS-BODY-006.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Round-8 test coverage found the last vacuously-satisfied term in this branch.
`MessageHeader.pendingBodyRequest` carries `headerComplete == true` and
`bodySettledRequest` deliberately omits it, and NOTHING pinned either fact:
every fixture that measured either request set `headerComplete = 1` by
construction, so both of the opposite edits left all 9,402 tests green.

That was measured, not argued. Deleting the conjunct from pendingBodyRequest and
adding it to bodySettledRequest were each run against the FULL suite — a
targeted run answers "does my new test catch it" but not "does anything else",
and the second question is the finding. Each mutation reddened exactly one test
of 9,403, the new one, on the matching assertion.

The distinguishing rows are reachable rather than hypothetical. NSEDataBridge
stages headers with headerComplete = false and flips the flag in a separate
statement, so an extension wake terminated between the staging write and the
flush leaves such a row behind durably.

Both consequences re-create the failure this branch exists to remove, from
opposite sides. Without the conjunct on pending, a staged row enters
pendingBodyCount and BackfillProgress.isFullyComplete is never true — the sync
banner never clears and Fast Sync holds the screen awake indefinitely. With the
conjunct added to settled, staged-but-settled rows leave the ftsIndexed
numerator while totalEmails still counts them, so "N / M indexed" parks
permanently below its denominator. One test pins both, each assertion paired
with a header-complete control of the same shape so neither can pass on an empty
result.

Also corrected: two funnel tests explained their own safety with the wrong
mechanism. They said no provider is registered, so the call cannot reach the
wire. That is backwards — the quarantine gate sits before the provider block, so
an absent provider is what causes connectAccount to be CALLED. What actually
keeps them offline is that the fixture's account has no imapHost, which makes
createIMAPProvider throw before any provider is built, registered in the
process-wide AccountManager.shared, or asked to connect. The tests were safe,
but for an unstated reason that a plausible future edit — adding host and port
to that fixture — would silently remove, turning them into live connection
attempts from the unit suite. Both comments now name the real guard and both
fixtures assert it.

Production code is untouched: `git diff HEAD -- TabMail/` is empty, so the
shipped bytes are identical to the previous commit.

Verification: full suite green on a fresh compile, dedicated simulator and
derivedData — 9,403 tests in 1,260 suites, one pre-existing known issue, zero
source-located compiler errors or warnings, and only the three documented-benign
AppIntents diagnostics. Both mutations restored and verified by SHA-256.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Round-9 test coverage found that the branch's headline user-visible behaviour
was reachable only through a mapping no test constrained. `fetchBody`'s tail
turns a BodyFetchProcessor outcome into a refusal — `.payloadTooLarge` becomes a
terminal class, `.retry` a retryable one — and both `loadBody` and the 2s body
poll branch on exactly that distinction via `BodyFetchRefusal.endsPolling`.

Both halves were tested and the join was not. The classifier test walks six
classes but constructs every refusal by hand; every MessageDetailViewModel test
injects `_fetchBodyOverride`; and all six other test call sites of `fetchBody`
exit before this tail, at the address gate, the quarantine gate, or
createIMAPProvider's missing-imapHost guard.

Measured, not argued: swapping the two arms left all 9,403 tests green. That
swap restores the exact defect this branch exists to remove — an oversized body
classifies as retryable, `loadBody` starts the poll, and the row is re-fetched
every two seconds forever, each tick a full TCP + TLS + LOGIN + SELECT plus a
folder connection teardown. The poll's own quarantine gate is no help in the
eviction case: an evicted row reads bodyComplete = 1, so isBodyQuarantined is
false.

The new test drives the funnel to completion against a registered mock provider,
which is what lets it reach the tail at all — a registered provider means
connectAccount is never called, so the host-less fixture account is not
consulted. Two legs in one run against two separate rows, because the overflow
leg durably flags its own row and reusing it would turn the control's refusal
into a quarantine rather than a retry. Under the swap both legs go red.

It asserts the CLASSIFICATION and never the integer code. Pinning -1/-2 would be
a mechanism-pinning test, which inherits a wrong spec's error and stays green on
a broken system.

Production code is untouched: `git diff HEAD -- TabMail/` is empty.

Verification: full suite green on a fresh compile, dedicated simulator and
derivedData — 9,404 tests in 1,260 suites, one pre-existing known issue, zero
source-located compiler errors or warnings, only the three documented-benign
AppIntents diagnostics. Anchored @test census is 9,404, matching the executed
count exactly. The mutation was restored and verified by SHA-256.

Signed-off-by: Kwang Moo Yi <kmyi@tabmail.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant