@@ -454,6 +454,13 @@ type DocClassification =
454454 | { type : 'unchanged' }
455455 | { type : 'drop' }
456456
457+ function shouldReplaceWithSkippedState (
458+ existing : { storageKey ?: string | null } ,
459+ skipped : Pick < ExternalDocument , 'skippedExistingDisposition' >
460+ ) : boolean {
461+ return existing . storageKey === null || skipped . skippedExistingDisposition === 'replace'
462+ }
463+
457464/**
458465 * Decides what a listed external document becomes during reconciliation.
459466 *
@@ -462,9 +469,11 @@ type DocClassification =
462469 * content stays last-known-good unless the connector marks the skip authoritative.
463470 * - `drop`: empty, non-deferred content that cannot be indexed.
464471 * - `add` / `update` / `unchanged`: normal content reconciliation by content hash.
465- * - A deferred listing always rehydrates an existing content-less placeholder,
466- * even when its listing hash is unchanged, so a prior hydration-time skip can
467- * recover when the source becomes indexable.
472+ * - A deferred listing rehydrates an existing content-less placeholder when its
473+ * listing hash changes. A connector whose skip must be retried independently
474+ * persists `skippedRetryContentHash`, which deliberately differs from the next
475+ * listing hash. Stable permanent skips therefore do not redownload or surface
476+ * as source failures on every sync.
468477 *
469478 * `forceRehydrate` (set on a full resync of a `rehydrateOnFullSync` connector) promotes
470479 * an otherwise-`unchanged` deferred document to `update` so its content is re-fetched —
@@ -487,7 +496,10 @@ export function classifyExternalDoc(
487496) : DocClassification {
488497 if ( extDoc . skippedReason ) {
489498 if ( ! existing ) return { type : 'skip' }
490- return existing . storageKey === null || extDoc . skippedExistingDisposition === 'replace'
499+ if ( existing . storageKey === null && existing . contentHash === extDoc . contentHash ) {
500+ return { type : 'unchanged' }
501+ }
502+ return shouldReplaceWithSkippedState ( existing , extDoc )
491503 ? { type : 'skip' , existingId : existing . id }
492504 : { type : 'unchanged' }
493505 }
@@ -497,9 +509,6 @@ export function classifyExternalDoc(
497509 if ( ! existing ) {
498510 return { type : 'add' }
499511 }
500- if ( existing . storageKey === null && extDoc . contentDeferred ) {
501- return { type : 'update' , existingId : existing . id }
502- }
503512 if ( existing . contentHash !== extDoc . contentHash ) {
504513 return { type : 'update' , existingId : existing . id }
505514 }
@@ -2312,7 +2321,7 @@ export async function executeSync(
23122321 // verified-unchanged match — same as the deferred-hydration
23132322 // equivalent above. A genuine hash match never sets skippedReason,
23142323 // so this only fires for the short-circuited case.
2315- if ( extDoc . skippedReason && existing ) {
2324+ if ( extDoc . skippedReason && existing && existing . storageKey !== null ) {
23162325 recordUnverifiedExistingRefresh ( result , failedExternalIds , extDoc . externalId )
23172326 } else {
23182327 result . docsUnchanged ++
@@ -2377,7 +2386,8 @@ export async function executeSync(
23772386 extDoc : mergeHydratedSkippedDocument ( op . extDoc , fullDoc ) ,
23782387 } )
23792388 } else if ( op . type === 'update' ) {
2380- if ( fullDoc . skippedExistingDisposition === 'replace' ) {
2389+ const existing = priorByExternalId . get ( op . extDoc . externalId )
2390+ if ( existing && shouldReplaceWithSkippedState ( existing , fullDoc ) ) {
23812391 skipOps . push ( {
23822392 type : 'skip' ,
23832393 existingId : op . existingId ,
0 commit comments