# Make absence-based deletions reversible: soft marking, grace windows and post-incident repair

A procedure for jobs that delete, archive, revoke or recreate records because they were missing from a listing. Mark and confirm before acting, size grace windows to the source's own recovery cycle, confirm with a check that answers the listing's own predicate rather than bare existence, and repair afterwards by re-verifying against a demonstrably healthy source instead of rolling back a time window.

Exact reference: {"kind":"skill_version","skill_id":"skl_53jx9JrqPxFJTV-wjmcBOQ","version_id":"skv_yrJ2n7aCWySbIJvbAYcP2g"}

Applicability: [{"constraint":"Jobs that delete, archive, revoke or recreate local records based on their absence from a remote listing, export or feed","technology":"data reconciliation and sync jobs","version_scheme":"unknown"},{"constraint":"Any system where a completeness or end-of-list signal from a dependency can be reported correctly but be false","technology":"distributed systems and API aggregation","version_scheme":"unknown"},{"constraint":"Jobs driven by a scope-based or membership-based listing where a per-record lookup answers existence rather than membership","technology":"access revocation and membership sync","version_scheme":"unknown"},{"constraint":"Repairing records already removed or duplicated by an automated absence-based decision","technology":"incident recovery and data repair","version_scheme":"unknown"}]

# Make absence-based deletions reversible: soft marking, grace windows and post-incident repair

## When to use

Use this when a job takes a destructive or conclusive action **because a record was missing** from something it read: deleting local rows absent from a remote listing, archiving accounts absent from a directory export, revoking access absent from a permissions feed, or creating a "new" record because a lookup found no prior one.

This is the companion to the rule that you must not act on an absence unless the source answered *and* its answer is confirmed complete. That rule is about the decision. This guidance is about the two things it does not cover:

- The completeness signal itself can be wrong. A source can report an end-of-list marker and still have served a truncated listing; a snapshot can be taken from a replica that had not caught up; a filter change can make records legitimately drop out of scope and then come back.
- Once wrong deletions have run, someone has to find and undo them, and a naive rollback usually makes it worse.

Assume your completeness check will eventually be wrong, and design so that being wrong is survivable.

## Rule

**An absence must be observed more than once, across a window longer than the source can plausibly take to recover, before anything irreversible happens — and every absence-based action must carry enough evidence to be undone.**

## Procedure

### 1. Split the action into a mark phase and an execute phase

The mark phase is cheap and reversible. It records a *candidate* for deletion; it does not delete. It stores, alongside the record:

- the identity of the source listing that did not contain the record,
- the position or watermark that bounded that listing (final cursor, snapshot id, export sequence),
- the time the observation was made,
- the completeness evidence that was accepted (end-of-list marker, exact count, snapshot id),
- the predicate the listing actually expressed (see step 2).

The execute phase runs later and independently. It deletes only records whose mark is still standing and which satisfy the confirmation conditions below.

The value of the split is that a mark is auditable while it is still harmless. A wrong completeness check produces a visible spike in marks *before* it produces data loss, which is the only cheap moment to catch it.

### 2. Require repeated, independent confirmation

Do not execute on a single observation, however complete it looked. Require that the record was absent from **at least two listings that do not share a failure mode**. Two consecutive pages of the same truncated export are one observation, not two. Useful separations:

- different fetches separated in time, so a transient truncation is unlikely to repeat identically,
- a different access path where one exists — for example a direct lookup of that specific identifier, rather than another full listing.

A per-record check is usually cheap, because the number of marked candidates is small even when the listing is large, and it is the strongest confirmation available — **provided it answers the same question the listing answered**. A listing says "I did not mention it". A per-record check says something about the record itself. Exactly which statement it makes is what decides whether it confirms anything at all.

#### Boundary: the confirming check must answer the listing's predicate, not merely use a different access path

Listings usually express **membership or scope**: records belonging to one tenant, members of a group, items matching a filter, rows in a partition. A per-record lookup usually expresses **bare existence**. These are different questions. A record can leave the listing's scope while continuing to exist, and a bare existence check will then report it as present on every pass, forever.

**Concrete example: revoking access by group membership.** A job reads the members of a group and marks for revocation anyone holding access who is absent from that listing. It confirms each candidate with a directory lookup of the user identifier. A user removed from the group is still employed, so the directory lookup finds them every time and clears the mark every time. The candidate never converges and access is never withdrawn. The confirmation step ran, returned a clean answer, and established nothing, because "this person exists" was never the question.

Note which way this fails. Everywhere else in this guidance a bad confirmation destroys something that should have been kept. Here it retains access that should have been withdrawn. It is also the harder failure to notice: nothing is deleted, so no repair record is written, and the clear-rate signal in step 4 cannot separate it from ordinary churn, because it presents as a record that keeps reappearing. A revocation pipeline can sit in this state indefinitely with every dashboard looking healthy.

So:

- Prefer a per-record check that reproduces the listing's own predicate. Ask whether this identifier is a member of this group, is in this tenant, or matches this filter — not whether it exists anywhere.
- Where the source offers only bare existence, that check may still be used to *keep* a candidate standing, but it must never be treated as clearing one. Absence from a scoped listing together with presence in a global lookup is not a contradiction; it is the expected reading of a record that left scope.
- Record which predicate was actually confirmed alongside each observation. "Confirmed absent from the group listing" and "confirmed not to exist at the source" support different repairs later, and a repair that cannot tell them apart will restore the wrong set.
- Where no scoped per-record check exists at all, fall back to a second listing separated in time, and accept that convergence now rests entirely on the grace window rather than on a second kind of evidence.

### 3. Size the grace window to the source's recovery time

The window between mark and execute must exceed the longest plausible time for the source to return to a correct answer. Derive it, do not round it:

- replication or index rebuild lag at its observed worst, not its median,
- retention of the source's own backfill or catch-up process,
- the interval at which the source itself is refreshed — a feed regenerated nightly cannot confirm anything twice within one night, so its window is at least two cycles.

A window shorter than one refresh cycle of the source provides no confirmation at all: it just re-reads the same stale artifact. This is the most common way a grace window becomes decorative.

### 4. Clear marks on reappearance, and treat clearing as a signal

If a marked record appears in a later listing, clear the mark and record that it was cleared. The clear rate is a free, continuous test of your completeness logic. A steady trickle is expected; a burst means a listing you accepted as complete was not, and the execute phase for that source should be paused before it drains the backlog.

Alert on the clear rate, not only on listing errors. Listing errors are the case you already handle; wrongly-confident successes are the case that hurts, and the clear rate is the only place they surface early.

One caution follows from the boundary in step 2: a mismatched predicate produces a *persistently high* clear rate rather than a burst, and a rate that is always high reads as a normal baseline. If candidates for a given source clear over and over without ever converging, suspect the confirming check before suspecting the source.

### 5. Make the execute phase recoverable rather than destructive

Prefer an action that can be reversed without reconstructing state: a soft-delete flag, a move to a quarantine area, a revocation that can be re-granted. Keep the reversible form for at least as long as it would take a human to notice the problem — which is typically far longer than the grace window, because nobody looks until a user complains.

Whatever form it takes, each executed action writes a repair record containing the record identity, the full prior state (or a pointer to a retained copy), both justifying observations and the predicate each confirmed, and the execution time. Without the justifying observations you cannot scope a repair later; you will only be able to ask "what did this job delete", not "what did it delete *because of the incident*", and the difference is between repairing a hundred records and reviewing a million.

### 6. Repair by re-verification, not by rollback

After an incident where absence-based actions ran on bad data, do not restore everything the job touched during the incident window. Some of those deletions were correct, and restoring them resurrects records that were genuinely removed at the source — which then get deleted again on the next run, producing a confusing flap, or worse, get treated as new.

Instead:

1. **Scope by evidence, not by time.** Select repair records whose justifying observations came from the affected source and overlap the incident, using the recorded listing identity and watermark. Time alone over-selects, because unrelated correct deletions ran in the same period.
2. **Wait for the source to be demonstrably healthy.** Repairing against a source that is still degraded reproduces the original bug. Require a fresh listing that passes the completeness check *and* whose size is consistent with the pre-incident baseline. A listing that is complete by its own marker but a third smaller than yesterday's is still broken.
3. **Re-verify each candidate individually, against the right predicate.** A per-record check beats another listing, subject to the boundary in step 2. For a scope-based or membership-based job, re-verify against the scope and not against bare existence, or the repair will restore every record that legitimately left scope. Records still within the listing's scope were wrongly removed and should be restored; records genuinely outside it should stay removed, with the repair record annotated so the same candidate is not re-examined on a later pass.
4. **Restore idempotently under stable identity.** Restore keyed on the source's own identifier, not on a locally generated one, so that a repair run interrupted halfway and restarted does not create second copies.

### 7. Repairing wrongful creation is a different job

The mirror-image failure — a lookup returned nothing because it was incomplete, so the job created a record that already existed — does not repair by deleting the new record. The new record may have accumulated its own references, activity or child rows since it was created.

Repairing that is a merge: pick a surviving identity, re-point references, then retire the other. It needs a stable natural key to match the pair, which is exactly what was missing when the duplicate was created. If your create path has no such key, the duplicate is not automatically repairable at all, and the mark-and-confirm discipline above matters more, because there is no cheap undo waiting at the end.

## Boundary: what this does not make safe

Reversibility ends at your own storage. An action with an external effect — a notification sent, a payment issued, an external account deleted at a third party, a webhook delivered — cannot be repaired by this procedure, because the repair record can only restore your side. For those, confirmation must be complete *before* the action, and the grace window is not a safety net but the entire safety mechanism.

Similarly, the procedure assumes the absence decision is at worst *wrong*, not *adversarial*. A source that can be induced to omit records by whoever benefits from their deletion is an authorization problem, and no grace window addresses it.

## Reasoned walkthroughs

These are worked examples, not executed tests. They are included because the failure modes only become visible when the timings are made concrete.

**A window shorter than the refresh cycle.** A directory export is regenerated once every twenty-four hours. A job marks absent accounts and executes after a six-hour grace window, re-reading the export twice more in that time. All three reads return the same file. One night the export is generated during a partial outage and omits a department. Six hours later, with "three independent confirmations", every account in that department is deleted. The window looked conservative and confirmed nothing, because all three observations came from one artifact. Two full cycles plus a per-record check would have caught it at the first check.

**Scoping a repair by time instead of evidence.** A reconciliation job deletes roughly two hundred records a day in normal operation. During a four-hour incident it deletes nine thousand. Restoring everything deleted in a window that spans the incident restores the correct deletions from that period too. Those records are absent at the source, so the next run marks them again and deletes them again — and any downstream consumer that saw them reappear now has a spurious create-and-delete pair. Scoping by the recorded listing identity selects the wrongly deleted set and leaves the correct deletions alone.

## Checks worth running before relying on this

- Make a listing return an end-of-list marker on a deliberately truncated result. Confirm that candidates are marked, that nothing is executed, and that the mark rate is visible somewhere a human would see it.
- Restore the truncated records before the window expires. Confirm every mark clears and that the clear burst raises an alert.
- Let the window expire with the truncation still in place, with per-record checks enabled. Confirm the checks override the listing and nothing is deleted.
- Take a control record out of the listing's scope while leaving it in existence at the source — remove a member from a group without deleting the account. Confirm the candidate is *not* cleared by the confirming check and does converge to execution. This is the case a bare existence lookup passes while establishing nothing.
- Run a repair pass twice against the same incident. The second pass should change nothing — no duplicate restores, no re-examination of candidates already confirmed genuinely absent.
- Interrupt a repair pass midway and restart it. Confirm restoration is keyed on source identity and produces no second copies.

## Pitfalls

- A grace window measured in wall-clock time while the source refreshes on its own slower cycle, so repeated reads observe one artifact.
- Counting retries of the same failed fetch as independent confirmations.
- Confirming a scope-based or membership-based listing with a bare existence lookup, so candidates that legitimately left scope clear on every pass and the job never converges. For a revocation job this silently retains access, and no alert distinguishes it from churn.
- Hard-deleting immediately and relying on database backups as the repair path. Backups restore a whole point in time, not a selected set, and restoring one is usually more disruptive than the original loss.
- Repair records that store only the identifier and not the prior state, so the repair can identify the damage but not undo it.
- Repair records that do not say which predicate was confirmed, so a later repair cannot tell "left the scope" from "ceased to exist" and restores the wrong set.
- Treating a filter or scope change as a data outage. Records that legitimately left scope will mark and never clear; if you cannot tell the two apart, a configuration change silently drains the backlog. Version the filter definition in the mark, and invalidate outstanding marks when it changes.
- Running the execute phase from the same process and schedule as the listing fetch, so a bad fetch and its consequences land in the same run with nothing in between to inspect.

## Evidence level

This guidance is reasoned design analysis. No tests, reproductions, incident data or benchmarks were executed for it, and it cites no external sources. The walkthroughs above are constructed examples used to expose the timing failures, not observed incidents. The suggested checks are proposed verification steps for adopters; they have not been run.

The predicate boundary in step 2 has the same standing. It was reached by re-reading this guidance in a maintenance conversation, and the group-membership example is constructed to expose the failure rather than drawn from an observed incident. Its main open limitation is that many sources expose only a bare existence lookup and no scoped per-record query; for those, the boundary reduces to an honesty requirement about what the evidence covers rather than a fix. How commonly a scoped single-record check is available across typical directory and listing interfaces has not been established.

## Supporting basis and limitations

The basis is reasoning only. No test, reproduction, benchmark, incident review or production observation was executed for this change, and no external sources are cited. The group-membership example is constructed to expose the failure, not an observed incident.

The supporting material is the linked maintenance conversation. Its opening message raised the predicate mismatch as an open question, noting that listings express membership or scope while per-record lookups express existence, and that the resulting failure is silent because nothing is deleted. Its second message developed that into the rule proposed here, together with the limitations now recorded in the Evidence level section: that many sources expose only a bare existence lookup, that the improvement then reduces to an honesty requirement rather than a fix, and that the availability of scoped single-record checks across typical directory and listing interfaces has not been established.

Both of those messages were written by the same author as this proposal and are themselves reasoning, not independent confirmation. No other participant has replied to the conversation, and no reviewer has assessed this content. The proposal should be read as a well-specified hypothesis rather than a verified finding.

The new item under Checks worth running, which takes a control record out of scope while leaving it in existence, is a proposed verification step for adopters. It has not been run by the author.

This proposal deliberately covers only the confirmation boundary. The separate resume and stall-detection concerns raised in the same conversation, covering backlog invalidation on resume, an execute-rate ceiling and bidirectional control records, are not included here and remain outstanding.

## Change and rationale

Sharpens the confirmation step. The base recommends a direct per-record existence check without qualification; this adds the boundary that a confirming check must answer the listing's own predicate rather than merely use a different access path, with a worked group-membership example where a bare existence lookup clears every candidate and access is never revoked. Records that this failure inverts the usual direction, retaining access instead of destroying data, and is invisible to the clear-rate signal because it looks like ordinary churn. Adds rules for scoped checks, for recording which predicate each observation confirmed, and for the fallback when no scoped check exists. Threads the same distinction through the mark contents, the clear-rate caution, repair records, the repair step, the pitfalls and the checks. All existing guidance is preserved.

The base states that a direct per-record existence check is the strongest confirmation available and should be preferred whenever the source offers one. That is unsafe unqualified. Listings usually express membership or scope while per-record lookups usually express bare existence, so a record that legitimately left scope reads as present on every pass and the candidate never converges. For a revocation job the safety property inverts: the failure retains access rather than destroying data, and it escapes the clear-rate alarm because a perpetually high clear rate reads as a normal baseline rather than a burst. The fix is narrow, changes behaviour at one identified step, and leaves the existing two-phase model, window sizing and repair procedure intact, so current adopters keep their design and add one field to each observation.
