antiwork/gumroad · error · Link::LinkInvalid
This save would remove versions that still have content, set
Error message
This save would remove versions that still have content, settings, or sales, which weren't explicitly removed in the editor. The version list shown may be out of date — please refresh the page and try again.
What it means
Product::VariantCategoryUpdaterService.ensure_deletion_intent! blocks any save whose payload would delete variants that 'matter' — ones with editor content or attached files, purchases, or non-default configuration (custom price, quantity cap, duration, PWYW, integrations, recurring prices, description) — unless each such variant's external_id appears in confirmed_removed_variant_ids. It exists because a payload built from stale or incomplete editor state would treat every missing variant as 'removed' and soft-delete the seller's version tree. It notifies ErrorNotifier, adds the message to product.errors, and raises Link::LinkInvalid.
Source
Thrown at app/services/product/variant_category_updater_service.rb:97
# treat every missing variant as "removed" and soft-delete the seller's
# entire version tree. Truly blank rows (no content, no purchases, all
# defaults) stay freely deletable so ordinary create-and-discard editor
# flows keep working without extra confirmations.
def self.ensure_deletion_intent!(product:, variants:, confirmed_removed_variant_ids:, diagnostics: {})
unconfirmed = variants.reject do |variant|
confirmed_removed_variant_ids.include?(variant.external_id) || !variant_requires_deletion_intent?(variant)
end
return if unconfirmed.empty?
ErrorNotifier.notify(
"Blocked product save that would delete configured, purchased, or content-bearing variants without confirmation",
product_id: product.id,
variant_ids: unconfirmed.map(&:id),
**diagnostics
)
message = "This save would remove versions that still have content, settings, or sales, which weren't explicitly removed in the editor. The version list shown may be out of date — please refresh the page and try again."
product.errors.add(:base, message)
raise Link::LinkInvalid, message
end
def self.variant_requires_deletion_intent?(variant)
variant_has_content?(variant) ||
variant_has_purchases?(variant) ||
variant_has_non_default_configuration?(variant)
end
def self.variant_has_content?(variant)
# has_editor_content? (not description.present?) so a variant whose only
# page is the editor's blank placeholder paragraph stays freely deletable.
variant.alive_rich_contents.any?(&:has_editor_content?) || variant.has_files?
end
# Any successful purchase means buyers rely on this variant existing (their
# library and receipts reference it), so deleting it must be an explicit
# seller decision. This closes the gap where
# VariantCategory#has_alive_grouping_variants_with_purchases? only shieldedView on GitHub (pinned to afeacbd394)
Solutions
- Refresh the product editor page and re-apply the changes on the fresh version list
- If the deletions are intended, remove those versions explicitly in the editor so each removal's confirmation id (confirmed_removed_variant_ids) rides along with the save
- Check the ErrorNotifier report — it carries product_id, the triggering variant_ids, and deletion_guard diagnostics to identify exactly which versions were unconfirmed
- If you build the payload programmatically, always re-fetch the current variant list right before saving instead of reusing a cached snapshot
Example fix
# before # payload built from a stale snapshot; server has 3 configured variants VariantCategoryUpdaterService.new(product:, category_params: stale_params).process # => Link::LinkInvalid: This save would remove versions... # after product.reload # pick up versions changed in another session # rebuild params from the fresh list; explicitly pass confirmations for intended removals VariantCategoryUpdaterService.new(product:, category_params: fresh_params, confirmed_removed_variant_ids: %w[vol_abc]).process
Defensive patterns
Strategy: try-catch
Validate before calling
# Pre-flight the same predicate the guard uses
fresh_variants = product.reload.variants.alive_too.map { |v| v } # re-read current state
unconfirmed = fresh_variants.reject do |v|
confirmed_ids.include?(v.external_id) ||
!VariantCategoryUpdaterService.variant_requires_deletion_intent?(v)
end
raise 'refresh required' if unconfirmed.any? Try / catch
begin
VariantCategoryUpdaterService.new(product:, category_params:).process
rescue Link::LinkInvalid => e
if product.errors[:base].any? { _1.include?('version list shown may be out of date') }
product.reload # re-fetch variants, rebuild payload, retry ONCE with fresh state
else
raise # a different validation failure — do not blind-retry
end
end Prevention
- Always re-fetch the product and its variants immediately before building the save payload — never reuse a cached snapshot
- Send confirmed_removed_variant_ids for every intentional removal of a configured/purchased/content-bearing version
- Treat ErrorNotifier reports of 'Blocked product save that would delete...' as a client bug until proven otherwise
- The guard fires per-save, not per-session: two editor tabs will keep clobbering each other until one is closed
When it happens
Trigger: The product editor tab was loaded hours ago and another tab/session changed the version list, so the save payload omits variants that still exist server-side; a client bug drops variants from the payload; a seller removes a purchased version but the confirmation ids are not sent along.
Common situations: Two editor tabs open at once; long-lived SPA state; scripts or third-party tools POSTing product saves built from a stale snapshot — precisely the RichContentDeletionGuard incident history the comment references.
Related errors
- Message is required
- That file is too large. Images can be up to #{MAX_IMAGE_BYTE
- We couldn't download that file, please check the URL and try
- You can attach up to #{DisputeEvidence::MAX_CUSTOMER_COMMUNI
- One of the uploaded files is not a JPG, PNG, or PDF.
AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21).
Data as JSON: /api/errors/7f440beae37d0737.
Report an issue: GitHub.