Index

Data That Must Survive

Soft Deletes Preserve Ambiguity Too

Adding deleted_at to a table answers one question: when was this row marked as deleted?

It does not answer whether the row was safe to mark, who should still see it, whether its credentials remain valid, whether it still occupies a unique business slot, how it can be restored, or when its data should be erased.

A retained cart cleanup could not safely reduce those questions to one nullable timestamp. Old carts might still have a shipment, a payment session, a booking attempt, or item work which had crossed a meaningful boundary. The eventual policy archives a deliberately narrow set and preserves the aggregate as evidence.

Soft deletion preserves rows. It also preserves every ambiguity which the lifecycle never defined.

Deletion is several decisions hiding behind one verb

“Delete this cart” can mean any of the following:

hide:
    ordinary application queries should no longer return it

close:
    no further business transitions may occur

release:
    it should no longer occupy the owner's active-cart slot

revoke:
    existing guest credentials must stop granting access

retain:
    business evidence and child records must remain available

erase:
    selected data must be destroyed after its retention obligation ends

One timestamp can participate in those decisions. It cannot define them.

This is why a generic soft-delete trait often feels complete during implementation and incomplete during an incident, restoration, or compliance review. It supplies a storage mechanism before the application has named the lifecycle.

Eligibility must describe what is safe to forget operationally

Age is necessary for cleanup, but age alone is weak evidence. A year-old record may still represent money, fulfilment, or an unfinished external effect.

The retained policy recognizes two candidate shapes:

  • an abandoned aggregate whose last update is older than its configured retention window; or
  • a pending aggregate whose expiry is older than a separate configured window.

It then excludes any candidate with:

  • a shipment;
  • an item in an added, retryable-failure, non-retryable-failure, paid, or shipped state;
  • any booking attempt; or
  • any payment session.

That list is conservative on purpose. A relationship can be enough to keep the record even when its current state looks disposable.

The useful question is not “is this row old?” It is:

Which evidence proves that no protected business process still depends on this aggregate?

If the system cannot answer that query, automatic cleanup is guesswork.

Retention rules need an evaluation time

Time-based eligibility should be evaluated against one captured instant:

evaluated_at = 2026-08-01 00:00:00Z
abandoned_cutoff = evaluated_at - abandoned_window
pending_cutoff = evaluated_at - pending_window

Every candidate in the batch uses those same cutoffs. A long-running job should not classify one record with the clock from its start and another with the clock from five minutes later.

The retained action receives an immutable evaluation time. Tests freeze it and exercise the exact boundary: a record at the cutoff is eligible; one a second inside the window is not.

This makes a dry run explainable and a retry comparable. “Old enough” becomes a reproducible predicate rather than whatever now() returned during each query.

Selection and mutation are different moments

A batch can select an eligible identifier and wait before changing it. During that gap, another request may add a protected item or create a payment relationship.

The retained archive action therefore:

  1. loads the aggregate under a row lock;
  2. stops if it is absent or already archived;
  3. reruns the complete eligibility policy under that lock;
  4. applies the archive transition; and
  5. commits before recording the activity.

The initial query finds candidates. The locked query grants permission to mutate.

That distinction also makes overlapping workers safe. Two workers may select the same identifier. Only one can perform the transition; the other observes the archived state and stops. Repeating the batch is a no-op for already archived rows.

Idempotency here is not a decorative job setting. It is a property of the state transition.

Archival should apply the whole lifecycle transition

The retained action does more than populate archived_at. In one transaction it also:

  • records a reason derived from the prior state;
  • revokes the guest credential if it was not already revoked;
  • exempts the aggregate from the current-owner uniqueness rule; and
  • preserves the aggregate and its item records.

After commit, the activity record includes the retention reason and policy version. The version lives in the evidence trail, not in the aggregate row itself.

These details prevent contradictory states:

hidden but still accessible by guest credential
hidden but still blocking creation of a current cart
hidden with no explanation of the policy which selected it
hidden after its child evidence was destroyed

Archival is a domain transition with side effects and invariants. Treating it as a bulk timestamp update would bypass the important parts.

Visibility should be named at the query boundary

Once archived, the aggregate disappears from ordinary queries but remains available through an explicit including-archived path.

That is more honest than pretending one global scope settles visibility for every reader. Different surfaces need different rules:

customer workflow:
    current, accessible aggregates only

support investigation:
    current and archived, subject to authorization

retention worker:
    current candidates, then explicit archived lookup for idempotency

audit or export:
    the subset allowed by its evidence and retention contract

Bypassing a default scope should be deliberate and reviewable. If callers scatter “include deleted” throughout the codebase, the application has replaced one vague default with many undocumented exceptions.

Restoration is not the inverse of setting a timestamp

Clearing archived_at might make a row visible. It does not necessarily make the aggregate valid.

Restoration would need to decide:

  • whether the prior state can become active again;
  • whether the owner already has a replacement aggregate;
  • whether guest access should be reissued rather than unrevoked;
  • whether related prices, sessions, or external references are still valid;
  • whether the archival reason permits reversal; and
  • which activity evidence records the decision.

The retained lifecycle does not implement a general restoration contract. The article should not smuggle one in by calling archival reversible.

If restoration is required, make it another guarded transition. Do not equate recoverable storage with a recoverable business process.

The retained case establishes when an aggregate can leave active workflows while its evidence remains stored. It does not establish when personal data must be deleted, which fields have a legal retention obligation, or whether anonymization is sufficient.

Those questions need a separate data inventory and policy:

data category
purpose
retention basis
minimum and maximum period
erasure or anonymization action
legal hold behavior
verification evidence

A soft-deleted row may still contain every original value. Calling it deleted does not reduce the privacy scope of backups, replicas, analytics, or support tools.

Operational archival and legal erasure can share mechanisms. They cannot share an unstated definition.

The fixture makes the policy disagree with age alone

The DATA-08 fixture evaluates synthetic aggregates at one fixed instant. An old expired aggregate with no protected relationships is eligible. Equally old aggregates with a shipment, protected item, booking attempt, payment session, or terminal state are not.

It then archives the eligible aggregate, revokes its guest access, releases its active-owner slot, preserves child evidence, and proves a second execution does nothing.

The fixture models the retained policy shape without executing private code. It does not prove a legal retention schedule, production volume, concurrent database locking, or an observed incident.

Define the lifecycle before choosing the column

Before adding soft deletes, answer:

Which states are eligible?
Which relationships veto the transition?
What becomes invisible, and to whom?
Which credentials are revoked?
Which uniqueness constraints are released?
What evidence remains?
Can the transition be restored?
When does retained data become eligible for erasure?
How is the policy version recorded?

Then choose columns, scopes, actions, and jobs which enforce those answers.

deleted_at may still be useful. So may archived_at, a terminal state, a separate history store, anonymization, or hard deletion. None of them is a retention policy by itself.