Summary
In the published 0.5.5 package (currently the latest on NuGet), DeleteOperation<TDocument>.OldDocument returns null unconditionally, ignoring the document that was passed to DocumentSet.Delete(entity). This silently breaks any logic that inspects a delete operation's OldDocument — most importantly the built-in soft delete, and any consumer interceptor that gates on the deleted document.
The fix is already present on main (public override object? OldDocument => _document;) but has not been published — 0.5.5 is the newest version on NuGet.
Root cause
Disassembly of the published MongoFlow 0.5.5 assembly:
DeleteOperation`1::get_OldDocument():
IL_0000: ldnull
IL_0001: ret
OldDocument returns a hardcoded null, even though DocumentSet.Delete(TDocument document) correctly constructs new DeleteOperation<TDocument>(filter, document, ...) and stores _document = document.
Current main (correct):
// src/Core/Operations/DeleteOperation.cs
public override object? OldDocument => _document;
Impact 1 — built-in soft delete never fires (silent hard delete)
SoftDeleteInterceptor<TSoftDeleteInterface>.SavingChangesAsync decides whether to convert a delete into an update by testing OldDocument:
if (operation.OperationType is OperationType.Delete
&& operation.OldDocument is TSoftDeleteInterface softDeleteDocument)
{
_options.ChangeIsDeleted(softDeleteDocument, true);
// ... convert Delete -> Update
}
With OldDocument == null, null is TSoftDeleteInterface is always false, so the branch is never taken, no exception is thrown, and the row is hard-deleted even though AddSoftDelete was configured. The soft-delete query filter works correctly (a row flagged IsDeleted = true is hidden from reads and revealed by DisableSoftDelete()); only the delete → update conversion is broken.
Impact 2 — delete-side interceptors silently skip deletes
Any consumer VaultInterceptor that reads operation.OldDocument for OperationType.Delete operations is also affected. For example, an authorization interceptor that gates delete permission checks like this:
case { OperationType: OperationType.Delete, OldDocument: not null }:
yield return (operation.OldDocument, PermissionDatabaseAction.Delete);
never matches, so delete operations are silently not authorization-checked. This is a security-relevant footgun for downstream consumers using MongoFlow as their authorization enforcement point.
Reproduction
- Define
class Thing : ISoftDelete { public bool Deleted { get; set; } ... }.
- Configure
builder.AddSoftDelete(new VaultSoftDeleteOptions<ISoftDelete> { IsDeletedAccessor = x => x.Deleted, ChangeIsDeleted = (e, v) => e.Deleted = v });.
- Add and save a
Thing.
- Load it,
vault.Things.Delete(thing), SaveAsync().
- Expected: the document remains with
Deleted = true. Actual (0.5.5): the document is physically removed.
Verified against real MongoDB in an integration test: DisableSoftDelete().Find(...) returns 0 rows and a raw driver query confirms the document is gone.
Fix
main already returns _document from OldDocument; please cut a release (e.g. 0.5.6) so the fix is consumable from NuGet. Until then, 0.5.5 consumers relying on soft delete or delete-side interceptor logic are silently affected.
Found while building real-MongoDB integration tests for MongoFlow's cross-cutting vault behavior (multi-tenancy + soft-delete). The tenant-isolation guarantees pass; this is the one behavior that surfaced the bug.
Summary
In the published 0.5.5 package (currently the latest on NuGet),
DeleteOperation<TDocument>.OldDocumentreturnsnullunconditionally, ignoring the document that was passed toDocumentSet.Delete(entity). This silently breaks any logic that inspects a delete operation'sOldDocument— most importantly the built-in soft delete, and any consumer interceptor that gates on the deleted document.The fix is already present on
main(public override object? OldDocument => _document;) but has not been published —0.5.5is the newest version on NuGet.Root cause
Disassembly of the published
MongoFlow0.5.5 assembly:OldDocumentreturns a hardcodednull, even thoughDocumentSet.Delete(TDocument document)correctly constructsnew DeleteOperation<TDocument>(filter, document, ...)and stores_document = document.Current
main(correct):Impact 1 — built-in soft delete never fires (silent hard delete)
SoftDeleteInterceptor<TSoftDeleteInterface>.SavingChangesAsyncdecides whether to convert a delete into an update by testingOldDocument:With
OldDocument == null,null is TSoftDeleteInterfaceis alwaysfalse, so the branch is never taken, no exception is thrown, and the row is hard-deleted even thoughAddSoftDeletewas configured. The soft-delete query filter works correctly (a row flaggedIsDeleted = trueis hidden from reads and revealed byDisableSoftDelete()); only the delete → update conversion is broken.Impact 2 — delete-side interceptors silently skip deletes
Any consumer
VaultInterceptorthat readsoperation.OldDocumentforOperationType.Deleteoperations is also affected. For example, an authorization interceptor that gates delete permission checks like this:never matches, so delete operations are silently not authorization-checked. This is a security-relevant footgun for downstream consumers using MongoFlow as their authorization enforcement point.
Reproduction
class Thing : ISoftDelete { public bool Deleted { get; set; } ... }.builder.AddSoftDelete(new VaultSoftDeleteOptions<ISoftDelete> { IsDeletedAccessor = x => x.Deleted, ChangeIsDeleted = (e, v) => e.Deleted = v });.Thing.vault.Things.Delete(thing),SaveAsync().Deleted = true. Actual (0.5.5): the document is physically removed.Verified against real MongoDB in an integration test:
DisableSoftDelete().Find(...)returns 0 rows and a raw driver query confirms the document is gone.Fix
mainalready returns_documentfromOldDocument; please cut a release (e.g.0.5.6) so the fix is consumable from NuGet. Until then,0.5.5consumers relying on soft delete or delete-side interceptor logic are silently affected.Found while building real-MongoDB integration tests for MongoFlow's cross-cutting vault behavior (multi-tenancy + soft-delete). The tenant-isolation guarantees pass; this is the one behavior that surfaced the bug.