Data Lifecycle
Published
A retention sweep is a program whose entire purpose is to destroy data on a schedule, and the only things standing between it and the records you are legally required to keep are property names on an options object. This area produced the most useful result of the pass, and it is not the worst outcome - it is the discovery that two options with identical-looking names failed in opposite directions, and that renaming both was safer than renaming one.
What the sample actually does
The file configures a nightly retention sweep the way a careful team configures it. Dry-run is switched off, because this job is meant to actually delete. The window is thirty days. The scope is one collection, events. Records are anonymised rather than hard-deleted. Children go with their parent. And the application supplies its own legal-hold check against the register of records under litigation.
The sweep runs over five rows chosen so each guard is the only thing saving a particular one. An expired event is what the job exists for. A record under litigation hold is old and unremarkable in every other way, so only the hold check saves it. A recent event is saved only by the window. And two rows - a statutory invoice kept for seven years, and the audit trail itself - are old, not under hold, and saved by nothing except the fact that their collections are not in scope.
Before any protected arm was read, each option was deleted from the sample's own configuration and the output compared. Every option moved the result except the one pinned equal to the library's default, which is the control.
The scheduler is copied in unprotected, because that is the shape of the real thing. Its defaults are the ones this family of helpers ships when handed an empty configuration - and they do not all point the same way, which turns out to be the whole story.
Protection alone was applied first, on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file. Every result below required member renaming aimed at the names.
Two option names, opposite disasters
Renaming the dry-run option reverted it to the library's default, which is true. The sweep stopped deleting anything at all: anonymized=ev-1001 became anonymized=(none). Nothing was destroyed, the job reported success, and the data it was supposed to remove piled up. That is a failure, but it is a quiet, recoverable, eventually-noticed one - and if your retention window is a compliance obligation in the other direction, it is still a finding.
Renaming the scope option reverted it to the library's default too, and the library's default for scope is null, meaning every collection. The measured line went from anonymized=ev-1001 to anonymized=ev-1001/inv-777/aud-900, with statutory-invoice-destroyed=false becoming true and audit-trail-destroyed=false becoming true.
Two option names on one object, lost by the same kind of pattern, one reverting to the safest possible value and one to the widest. The severity of losing a name is not a property of the name, or of how important the option sounds. It is a property of what that particular library chose as its default, which is written in its documentation and nowhere in your code.
The invoice row is the one to hold on to. Nothing about it is unusual: it is old, it is not under hold, and no rule anywhere says "protect invoices". It survived only because invoices was not on a list - and a list you cannot read is indistinguishable from a list you never supplied.
The safe half hides the destructive half
Renaming both options together should, on any intuition about severity, be worse than renaming either. It was measured, and it is not. The combined arm is behaviourally identical to renaming dry-run alone: nothing is deleted, the statutory invoice survives, the audit trail survives, and the run reads clean.
The reason is ordering. Dry-run reverts to true and is consulted before anything is destroyed, so the widened scope never gets to act. The safe default masks the destructive one completely.
This inverts a rule this series established earlier, in which a strict control sitting behind an enabling flag produced the silent outcome rather than the loud one because the flag was checked first. The mechanism is the same - one option gates another - but the direction is reversed, and the reversal has a consequence for how any audit of this kind must be read.
You cannot rank findings by how many names a pattern reaches. A wider pattern here is strictly safer than a narrower one. And more importantly: a combined arm that reads clean is not evidence that its halves read clean. If this area had been measured only with the broad, realistic "rename everything in the options object" pattern - which is what most people would test - the destruction of the statutory records would not have appeared at all. It only shows up when the scope option is lost and the dry-run option is not, which is precisely what a narrow, well-intentioned, carefully-scoped pattern produces.
The general form is uncomfortable and worth stating plainly: in a matrix like this, the dangerous cells are not at the wide end. Test the halves, not just the union.
The hold register, and the worst arm in the file
Renaming the option carrying the application's legal-hold check did not disable holds. It substituted the library's builtin, which holds nothing, and the record under litigation was destroyed along with the rest: held=ev-8001(litigation-hold-2026-04) became held=(none), and held-record-survived went from true to false. The reported hold registry moved from caller-supplied(checks the hold registry) to library-builtin(nothing is ever held).
Combining that with the scope loss produced the worst result in the area: anonymized=ev-1001/ev-8001/inv-777/aud-900. The litigation record, the statutory invoice and the audit trail, all destroyed in one scheduled run, by a job that reported success. Neither option gated the other, so nothing masked anything.
Two smaller arms round it out. Renaming the anonymise option flipped the sweep from blanking records to hard-deleting them, which is the difference between a record you can still count and one that is gone. Renaming the cascade option left orphaned children behind, which is a data-quality problem rather than a compliance one but is exactly as invisible.
For completeness, the loud direction: renaming the stored row's own collection and ageDays fields made every row unreadable to the scope and age checks, so nothing matched and nothing was purged. record-readback read collection=MISSING ageDays=MISSING. That fails safe here, but only by luck of which side of the comparison went missing.
What to do about it
Scope the rename. RenameMembers takes a MemberRegexp, and the names to keep out of it are the option keys and record fields of anything you did not compile yourself.
Then check the defaults of every retention-shaped option you set, and write down which direction each one fails. This is a five-minute exercise with the library's documentation open, and it is the only way to know whether losing a given name stops your purge or widens it. Options whose default is the permissive one deserve a runtime assertion; options whose default is safe mostly do not.
Probe rather than review. Run the sweep against a fixture containing one row from a collection that must never be purged and one row under hold, and require both to survive. Assert the scope list is non-empty at startup rather than trusting it to be read. Both fail loudly the moment a name has moved.
And take the measurement discipline with you: when you test whether a transformation is safe, test the narrow patterns as well as the broad one. The broad one here was clean.
Frequently asked questions
Does protecting my JavaScript break a retention or purge job on its own?
Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at property names.
What was the worst result?
Renaming the purge-scope option together with the legal-hold option. The sweep widened from one collection to every collection and stopped honouring holds, destroying a record under litigation, a statutory invoice and the audit trail in a single run that reported success.
Why was renaming two options safer than renaming one?
Because the two library defaults point in opposite directions and one gates the other. Dry-run reverts to true and is checked first, so it stops the sweep before the widened scope can act. Renaming both is behaviourally identical to renaming dry-run alone.
What does that mean for how I test this?
That a clean result from a broad pattern proves nothing about narrower ones. Test the halves as well as the union. Here the destructive outcome appears only under a narrow, carefully-scoped pattern, which is exactly what a cautious team would write.
Is the severity related to how important the option sounds?
No. It is decided by what the library chose as that option's default. Two options on the same object, lost the same way, produced a job that deleted nothing and a job that deleted everything.
Did legal holds get switched off?
They were downgraded rather than removed. The application's hold check was replaced by the library's builtin, which holds nothing, while the configuration still read as though a hold registry were installed.
What is the smallest change that prevents all of this?
Scope RenameMembers with a MemberRegexp that excludes installed dependencies' option names, then assert at startup that the scope list is non-empty and run the sweep against a fixture holding one never-purge row and one held row.
Related reading