fix(lineage): dedup path always soft-supersedes + commit-atomic legacy log - #206
Conversation
Change supersede_profiles_by_ids return from int (rowcount) to list[str]
(the profile ids actually superseded, in input order). Behavior is
otherwise identical: same eligibility gate (status in {NULL/CURRENT,
PENDING}), same empty-request_id raise, same tombstone fields, same one
status_change/superseded lineage event per committed id under the shared
request_id, same user_id scoping and rowcount guarding.
This is the storage-contract prerequisite for building a commit-atomic
legacy ProfileChangeLog 'removed' entry from what supersede actually
committed (rather than the deduplicator's intent).
Abstract contract + SQLite impl + the storage integration tests that
asserted the count are updated to the list[str] contract.
…gacy log Replace the dedup-path soft/hard branch in _finalize_extracted_items with an unconditional supersede_profiles_by_ids; remove the now-dead is_dedup_soft_delete_enabled flag and the silent hard-delete fallback. - Empty request_id now fails loud via capture_anomaly and skips removal (never hard-deletes), since the legacy log + lineage events are keyed on it. - The legacy ProfileChangeLog removed_profiles are built from the ids supersede ACTUALLY committed, not the deduplicator's intent, so a failed/skipped/partial supersede never writes a phantom removal (commit-atomic invariant). - Retire is_dedup_soft_delete_enabled from feature_flags and its tests; delete the obsolete flag-centric dedup soft-delete integration test.
…atomic legacy log) Replaces the deleted test_dedup_soft_delete_integration.py with invariant-focused tests for the always-soft dedup path: - happy path on real SQLiteStorage: removed profile is SUPERSEDED (not absent), exactly one status_change/superseded event under the run's request_id, zero hard_delete events, legacy removed_profiles == committed set, and reconstruction MATCHes legacy (parity divergence 0). - failure path (mock storage): supersede raises / partial-commits -> legacy removed_profiles records only what committed (no phantom removal); run does not raise. - empty request_id (mock storage): supersede and delete_user_profile NOT called, capture_anomaly fires lineage.dedup.missing_request_id, no removal logged. - regression guard: dedup path supersedes and never hard-deletes.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (9)
💤 Files with no reviewable changes (2)
📝 WalkthroughWalkthroughDedup removals are made unconditionally soft-superseded by changing ChangesAlways-soft dedup supersede
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary
Closes the dedup-path hard-delete instrumentation gap — the last write-side prerequisite for retiring the legacy
ProfileChangeLog(Track-A). The profile deduplication path now always soft-supersedes removed profiles instead of sometimes hard-deleting them unreconstructably, and the legacy change-log is made commit-atomic so it can never record a removal that didn't actually commit.The problem
The dedup removal path had a hard-delete fallback (when
is_dedup_soft_delete_enabledwas off orrequest_idwas empty) thatDELETEd the row and emitted ahard_deleteevent under a throwawayuuid4()— uncorrelatable to the run, so reconstruction could never reproduce it. Separately, the legacyProfileChangeLog.removed_profileswas built from the deduplicator's intent, written unconditionally even if supersede threw/skipped/partially no-op'd → a phantom "removed" entry with no matching tombstone → permanentCONTENT_MISMATCHblocking the parity gate.The fix
is_dedup_soft_delete_enabledflag).supersede_profiles_by_idsnow returns the committed ids (list[str], wasintrowcount) across all backends, so the service knows exactly what was tombstoned.removed_profilesis built from the committed set, not the intent. Supersede exception →committed=[]; emptyrequest_id→ fail-loudcapture_anomaly("lineage.dedup.missing_request_id")+ skip (never hard-delete). Legacy and reconstruction agree by construction.Invariant + tests
A dedup removal either soft-supersedes (tombstone + one
status_change/superseded event under the run'srequest_id) AND appears in legacyremoved_profiles— or neither; never a phantom removal, never ahard_delete. Newtest_dedup_always_soft_integration.py: real-storage happy path asserting reconstructionMATCH, plus supersede-raises / partial-commit / empty-request_id/ regression guards.Review
Plan reviewed via
/review-design-doc(3 lenses; the adversarial lens surfaced the phantom-removal/atomicity class this fix kills). Built via subagent-driven-development with per-task reviews + a whole-branch (opus) review that traced the invariant across all 3 backends.Plan:
docs/superpowers/plans/2026-06-22-lineage-dedup-hard-delete-fix.md(enterprise repo).Summary by CodeRabbit
Release Notes
New Features
Bug Fixes