Skip to content

fix(knowledge): replace deletion-safety heuristics with a two-phase tombstone#5884

Open
waleedlatif1 wants to merge 1 commit into
stagingfrom
fix/gsheets-tombstone-reconciliation
Open

fix(knowledge): replace deletion-safety heuristics with a two-phase tombstone#5884
waleedlatif1 wants to merge 1 commit into
stagingfrom
fix/gsheets-tombstone-reconciliation

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #5883, which merged before this redesign was ready. #5883 fixed the immediate bug (trashed Google Sheets tabs never purging on a normal sync) with sourceConfirmedEmpty — a connector-set flag bypassing two static safety heuristics (shouldSkipEmptyListing, exceedsDeletionSafetyThreshold). On reflection that approach was the wrong shape: a boolean escape hatch that any future connector could set to bypass both deletion safety nets at once, with no protection against the source itself being transiently wrong.

This PR replaces it with a proper two-phase tombstone, matching how production sync systems actually solve this (Microsoft Entra Connect's deletion threshold, SCIM/Entra ID's deferred-delete deprovisioning, Cassandra/Couchbase tombstones): never let a single observation trigger an irreversible mass deletion, no matter how confident the signal looks.

  • A document missing from a normal sync's listing is now soft-deleted (marked pending-removal), not purged immediately.
  • It's only hard-deleted once a later sync confirms it's still absent.
  • If it reappears in between, it's resurrected automatically — self-heals a transient outage or bad API response without needing to distinguish "real" emptiness from "ambiguous" emptiness at all.
  • A forced full sync still purges everything absent in one pass — the existing "trigger a full sync to force cleanup" escape hatch is preserved.
  • Removes shouldSkipEmptyListing, exceedsDeletionSafetyThreshold, and sourceConfirmedEmpty entirely. Google Sheets no longer needs any connector-specific bypass — it goes through the exact same general path as all 8 connectors, with zero special-casing and zero misuse surface for future connectors.
  • Uses the existing (previously unused for individual documents) document.deletedAt column as the tombstone marker — no schema migration.
  • shouldReconcileDeletions (the isIncremental/listingCapped/listingTruncated gate) is unchanged. Resurrection runs unconditionally even when that gate is closed, since presence is trustworthy evidence regardless of listing completeness.

Type of Change

  • Bug fix / design improvement

Testing

  • Added 7 new unit tests for partitionSyncReconciliation (resurrect / soft-delete / hard-delete / mixed-batch / fullSync / null-externalId cases), replacing the removed heuristic tests.
  • bun run lint, tsc --noEmit, and the full connectors + lib/knowledge + background/knowledge-connector-sync test suites (508 tests) all pass.
  • Ran /cleanup's full 8-pass audit — no dead code, no stale/redundant comments, nothing to fix.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

…ombstone

PR #5883 merged an interim fix (sourceConfirmedEmpty bypassing two
static safety heuristics) before this follow-up redesign was ready.
This supersedes that approach with a properly general fix, matching
how production sync systems (Entra Connect, SCIM/Entra ID
deprovisioning, Cassandra/Couchbase tombstones) handle this exact
problem: never let a single observation trigger an irreversible mass
deletion, no matter how confident the signal looks.

A document missing from a normal sync's listing is now soft-deleted
(marked pending-removal) rather than hard-deleted immediately. It's
only actually purged once a *later* sync confirms it's still absent.
If it reappears in between, it's resurrected automatically — this
self-heals a transient outage or a bad API response without needing
to distinguish 'real' emptiness from 'ambiguous' emptiness at all,
which is what the removed heuristics were trying (and failing) to do
from a single observation.

This removes shouldSkipEmptyListing, exceedsDeletionSafetyThreshold,
and sourceConfirmedEmpty entirely — Google Sheets no longer needs a
connector-specific bypass flag, since a genuinely trashed spreadsheet
now reconciles through the exact same general path as every other
connector, with no special-casing and no new misuse surface for
future connectors. A forced fullSync still purges everything absent
in one pass, preserving the existing 'trigger a full sync to force
cleanup' escape hatch.

Uses the existing (previously unused for individual documents)
document.deletedAt column as the tombstone marker — no schema
migration required. shouldReconcileDeletions (the isIncremental /
listingCapped / listingTruncated gate) is unchanged; it still governs
whether reconciliation may run at all. Resurrection runs
unconditionally even when that gate is closed, since presence is
trustworthy evidence regardless of whether the listing was complete.
@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 23, 2026 6:55am

Request Review

@cursor

cursor Bot commented Jul 23, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes core knowledge connector deletion behavior (mass-delete timing and recovery), though the design reduces accidental wipes; uses existing deletedAt with no schema migration.

Overview
Replaces connector sync deletion safety heuristics (shouldSkipEmptyListing, exceedsDeletionSafetyThreshold, and sourceConfirmedEmpty) with a two-phase tombstone flow driven by partitionSyncReconciliation.

On a normal sync, documents missing from the listing are soft-deleted (document.deletedAt set) instead of hard-deleted immediately; only tombstoned docs still absent on a later sync are purged. Docs that reappear while pending removal are resurrected unconditionally. Forced full sync still hard-deletes everything absent in one pass.

The sync engine loads live and tombstoned rows, merges them for upsert classification, and applies resurrect / soft-delete / hard-delete after listing. Google Sheets no longer sets syncContext.sourceConfirmedEmpty when a spreadsheet is trashed—it relies on the same path as other connectors. Tests swap heuristic coverage for partitionSyncReconciliation cases and simplify Google Sheets listDocuments expectations.

Reviewed by Cursor Bugbot for commit f0a00b8. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f0a00b8. Configure here.

logger.info(`Resurrected ${resurrectIds.length} documents that reappeared at the source`, {
connectorId,
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resurrection runs after blocked updates

Medium Severity

When a pending-removal document reappears in the listing, sync classifies it as an update and runs updateDocument while deletedAt is still set. That helper only updates rows with null deletedAt, so content refresh fails and the sync counts a failure. deletedAt is cleared afterward in resurrection, leaving the document active with stale indexed content until a later sync.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f0a00b8. Configure here.

logger.info(
`Marked ${softDeleteIds.length} documents pending removal — absent from source, confirming on next sync`,
{ connectorId }
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tombstoned docs escape connector cleanup

Medium Severity

This change marks absent connector documents pending removal by setting deletedAt. Connector teardown and sync-abort cleanup still select only rows with null deletedAt, so pending-removal documents are never hard-deleted. They can keep embeddings and storage after the connector is removed, with connectorId nulled by the foreign key.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f0a00b8. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces immediate deletion heuristics with two-phase document tombstones.

  • Normal reconciled syncs mark newly missing documents pending removal and purge previously tombstoned documents that remain absent.
  • Reappearing documents are restored automatically, while forced full syncs retain immediate deletion behavior.
  • Google Sheets no longer sets a connector-specific empty-source override.
  • Unit tests now cover reconciliation partitioning and Google Sheets’ simplified listing behavior.

Confidence Score: 3/5

The PR should not merge until changed tombstoned documents can be updated successfully before they are made active again.

A reappearing tombstone with changed content follows the existing-document update path, but persistence rejects deleted rows while later reconciliation still clears deletedAt, making stale content searchable.

apps/sim/lib/knowledge/connectors/sync-engine.ts and apps/sim/lib/knowledge/connectors/sync-engine.test.ts

Important Files Changed

Filename Overview
apps/sim/lib/knowledge/connectors/sync-engine.ts Implements two-phase reconciliation, but changed tombstoned documents can be reactivated after their update fails, exposing stale content.
apps/sim/lib/knowledge/connectors/sync-engine.test.ts Replaces heuristic tests with partition tests, but does not exercise executeSync when a reappearing tombstone requires an update.
apps/sim/connectors/google-sheets/google-sheets.ts Removes the Google Sheets-specific sourceConfirmedEmpty mutation and delegates deletion safety to the general reconciliation flow.
apps/sim/connectors/google-sheets/google-sheets.test.ts Updates trashed-spreadsheet tests to match the simplified connector contract.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  L[Connector listing] --> P{Stored document present?}
  P -->|Present and active| K[Keep or update]
  P -->|Present and tombstoned| R[Resurrect]
  P -->|Absent and active| F{Forced full sync?}
  F -->|No| S[Set deletedAt tombstone]
  F -->|Yes| H[Hard delete]
  P -->|Absent and tombstoned| H
Loading

Reviews (1): Last reviewed commit: "fix(knowledge): replace deletion-safety ..." | Re-trigger Greptile

Comment on lines +606 to 610
const priorByExternalId = new Map(
[...existingDocs, ...tombstonedDocs]
.filter((d) => d.externalId !== null)
.map((d) => [d.externalId!, d])
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Resurrection exposes stale document content

When a tombstoned document reappears with changed content, this map classifies it as an update even though persistence only accepts rows whose deletedAt is null. The failed update is handled per-document, but reconciliation still clears deletedAt because the external ID was seen, causing the old content and embeddings to become searchable again.

Knowledge Base Used: Knowledge (RAG) Module

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant