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 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, but 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 can feel finished when the code is written and woefully 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 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 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. More importantly, “old enough” becomes a reproducible predicate instead of whatever now() happened to return 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 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, but only one can perform the transition. The other observes the archived state and stops, and a later run is a no-op for an already archived row. Idempotency here is not a decorative job setting. It is a property of the state transition.

Archival should apply the whole lifecycle transition

The 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

That is why archival is a domain transition with side effects and invariants, not a bulk timestamp update with a nicer name.

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 not solved visibility. It has replaced one vague default with a pile of 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.

That lifecycle does not implement a general restoration contract, so calling the archive reversible would be dishonest. If restoration is required, make it another guarded transition. Recoverable storage does not automatically make the business process recoverable.

The 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, but they cannot safely 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 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. The mistake is asking any one of them to stand in for a retention policy the application never defined.