Deleting in Git and Rethinking (Philosophically) Deletion as a System Design Question

Yesterday I removed a file that no longer belonged in one of my Git repositories. It had already been committed and pushed several times, so rather than simply deleting the current copy, I decided to clean up the corresponding history as well.

What looked like a straightforward maintenance task became more complicated once I started verifying the result. The file disappeared from the current branch, then from reachable history, but some of the old commit IDs could still be resolved by the hosting service. From there, the problem became less about the file itself and more about Git’s object model, reference reachability, hosted repository state, and what can actually be verified after a history rewrite.

I started with a practical cleanup problem. I ended with a much more general question:

What does “deleted” actually mean in a distributed version-control system?

Deleting a file is easy until history becomes part of the requirement

At the filesystem level, deletion is wonderfully simple:

rm path/to/target-file.md

Inside Git, the ordinary version would be:

git rm path/to/target-file.md
git commit -m "Remove obsolete file"
git push

For everyday repository maintenance, that is usually exactly what I want. The current branch no longer contains the file, collaborators receive the deletion, and development continues.

But Git has not forgotten the file.

Older commits still contain the corresponding tree and blob objects. If I know an earlier commit, I can still inspect the old version:

git show OLD_COMMIT:path/to/target-file.md

This is not a defect. It is one of the central properties of version control.

Git is designed to answer questions such as:

  • What did this project look like yesterday?
  • Who changed this line?
  • Can I reproduce an older release?
  • Can I recover something deleted six months ago?

So deleting a file from today’s tree and deleting its historical representations are fundamentally different operations.

The first changes the newest state.

The second changes the graph.

The problem became easier once I stopped thinking of Git as folders

A working directory makes Git look filesystem-like. I see directories and files, edit them, and commit the result.

Internally, however, Git is much closer to a content-addressed object graph.

A simplified view is:

branch ref
    |
    v
  commit
    |
    v
   tree
  /    \
blob   subtree
        |
       blob

A branch such as main points to a commit. That commit references a tree representing the directory structure, and the tree references other trees and blobs. Each commit also normally references one or more parent commits.

The history therefore becomes a chain, or more generally a directed acyclic graph:

C1 <- C2 <- C3 <- C4 <- C5 <- main

If my unwanted file appeared in C3, deleting it in C5 does not alter C3.

The old commit remains an immutable description of an earlier state.

Once I understood the cleanup in those terms, the real task became clearer:

I did not merely want to create a new commit in which the target file was absent. I wanted to construct a new reachable history in which the old file had never appeared.

That is a history-rewriting problem.

My first plan was the general-purpose solution

The standard modern tool for this type of work is git-filter-repo.

A path-based removal can look like:

TARGET='path/to/target-file.md'

git filter-repo \
  --sensitive-data-removal \
  --invert-paths \
  --path "$TARGET"

The terminology of the option is broader than my immediate problem. Technically, what matters here is --invert-paths: keep everything except the target path while rewriting the relevant history.

This is powerful because it operates across historical commits rather than merely producing another deletion commit.

After filtering, commits containing the path may receive completely different object IDs, because Git commit identifiers depend on their contents, metadata and ancestry.

Conceptually:

before

A <- B <- C <- D
         ^
         target appears here


after rewriting

A <- B' <- C' <- D'

Depending on what changed in those commits, some may disappear entirely and others may survive in rewritten form.

I initially expected to use this general-purpose solution.

Then I inspected the repository more closely.

The commit topology changed my choice of tool

Before rewriting anything, I asked Git exactly where the target file appeared:

TARGET='path/to/target-file.md'

git log --all --follow --name-status -- "$TARGET"

Then:

git log --all --oneline -- "$TARGET"

The result was unusually convenient.

The target file had only been involved in the most recent few commits. More importantly, those commits formed one continuous suffix at the tip of main.

I identified the first affected commit and its parent:

FIRST_AFFECTED='OLD_SHA_1'
BASE="$(git rev-parse "${FIRST_AFFECTED}^")"

echo "$BASE"

Then I inspected everything that had changed between that clean base and the current HEAD:

git diff --name-status "$BASE"..HEAD

The affected surface was tiny. The relevant commits had changed only the target and a small amount of associated repository metadata.

No unrelated project content had been modified in the same interval.

This changed the problem substantially.

I no longer needed to transform an arbitrary graph spanning years of history. I had a short contiguous suffix that could safely be replaced by a new sanitized successor to the last unaffected commit.

So instead of immediately using the most powerful available tool, I changed the plan.

This was one of the more useful lessons of the whole process:

Choose the destructive primitive after inspecting the topology, not before.

A general-purpose history rewriter was technically capable of doing the job. But once I knew the exact structure, a smaller operation became easier to reason about and easier to verify.

Installing a powerful tool and then deciding not to use it is perhaps one of the quieter signs that a debugging session is going well.

Before rewriting history I wanted invariants

The dangerous part of history rewriting is not simply that commit IDs change.

The dangerous part is accidentally changing something that was never supposed to be part of the operation.

So before touching the history, I created a checksum snapshot of the files that should remain unchanged.

In simplified form:

find entries -type f \
  ! -path "$TARGET" \
  -exec shasum -a 256 {} + |
LC_ALL=C sort \
> /tmp/files-before.sha256

After the rewrite I could calculate the same set again:

find entries -type f \
  ! -path "$TARGET" \
  -exec shasum -a 256 {} + |
LC_ALL=C sort \
> /tmp/files-after.sha256

And compare them:

diff -u \
  /tmp/files-before.sha256 \
  /tmp/files-after.sha256

Ideally:

[no output]

This is a very simple technique, but conceptually it changed the operation from:

I think I only modified the intended files.

into:

Every protected file still has exactly the same byte-level digest.

For destructive maintenance, I increasingly prefer this kind of negative invariant.

The goal is not merely to test what I intentionally changed. It is also to prove, within a well-defined universe, what I did not change.

I also guarded the expected change set itself

The same idea applied to Git’s view of the affected range.

I knew which paths I expected to see:

EXPECTED="$(printf '%s\n' \
  'README.md' \
  "$TARGET" |
  LC_ALL=C sort)"

ACTUAL="$(git diff --name-only "$BASE"..HEAD |
  LC_ALL=C sort)"

Then:

if [ "$ACTUAL" = "$EXPECTED" ]; then
    echo "GOOD: affected history matches expected scope"
else
    echo "STOP: unexpected paths exist"
    echo
    echo "EXPECTED:"
    printf '%s\n' "$EXPECTED"
    echo
    echo "ACTUAL:"
    printf '%s\n' "$ACTUAL"
    exit 1
fi

I like this pattern because the script is not being asked to decide whether an unexpected difference is harmless.

Unexpected simply means stop.

That is the correct personality for a script sitting next to a history-rewriting command. Mild paranoia is a feature : ).

I constructed the desired present before reconstructing the past

Once I knew the boundary, I first prepared the working tree exactly as I wanted it to exist after cleanup.

The target file was removed, the relevant index information was adjusted, and everything else was left untouched.

Then came the key operation:

git reset --soft "$BASE"

A soft reset moves the current branch pointer while preserving the index and working-tree state.

This makes it particularly useful for this topology.

Before:

A <- B <- C <- D <- E <- main
         clean    affected

After moving main back to the clean boundary:

A <- B <- main

working tree:
the new desired state

I could then stage only the paths that belonged to the reconstructed successor commit:

git add -A -- \
  README.md \
  path/to/target-file.md

Depending on the final desired state, an explicitly retained replacement or metadata path can be included in that same command.

I intentionally avoided a blind:

git add .

There is nothing universally wrong with git add .. But this procedure had been designed around an extremely narrow mutation boundary, so explicit staging made the operation easier to inspect.

The index briefly looked stranger than the repository actually was

After manipulating the branch boundary and current working state, git status --short temporarily displayed combinations such as:

MM README.md
AD path/to/target-file.md
?? path/to/new-file.md

This is where understanding Git’s index becomes useful.

The two status columns represent different comparisons:

XY PATH

X = HEAD versus index
Y = index versus working tree

So something like:

MM README.md

does not mean Git has become existentially uncertain about the README. It means one modification exists between HEAD and the index and another modification exists between the index and working tree.

After staging the intended final state, the output became much simpler.

I verified that staged paths exactly matched the expected set:

git diff --cached --name-status
git diff --cached --stat

And inspected the actual patch:

git diff --cached

Only after that did I create the replacement commit.

git commit -m "Rebuild current repository state"

The branch now moved directly from the last clean historical commit to the desired current state.

The old affected suffix was no longer part of main.

One of my bugs was not a Git bug at all

During this process I opened a new Terminal window and continued with a command that depended on a variable I had defined earlier:

rm -- "$TARGET"

The shell responded approximately:

rm: : No such file or directory

The problem was simply that the new shell did not know what TARGET meant.

The variable belonged to the previous process.

My mental state had survived the new Terminal window. The shell’s had not.

This sounds trivial, but it revealed a genuine procedural weakness. A multi-step destructive workflow should not depend on remembering which variables happen to exist in which interactive shell.

A safer script would begin with:

set -u

: "${TARGET:?TARGET must be defined}"
: "${BASE:?BASE must be defined}"

Then an unset variable becomes an intentional stop condition rather than an empty argument quietly entering a destructive command.

Another bug involved accidentally executing Git output

At another point I copied output similar to:

M README.md
D path/to/target-file.md
?? path/to/new-file.md

and pasted it back into zsh.

Zsh then attempted to execute M, D and ?? as commands.

Nothing harmful happened. The shell merely complained.

But it reinforced another design lesson: human operators are part of the system.

When I think about safety around destructive commands, the threat model should not be limited to “Git might behave unexpectedly.”

It should also include:

  • opening a new terminal;
  • losing shell variables;
  • pasting output as input;
  • running a command from the wrong directory;
  • misreading staged versus unstaged state;
  • assuming a previous command succeeded;
  • forgetting that a local clone still contains old objects.

Good maintenance tooling makes these ordinary human mistakes boring rather than catastrophic.

My checksum test failed because of .DS_Store

After reconstructing the history, I ran the before-and-after checksum comparison.

It showed one difference.

For a moment, this looked bad.

Then I inspected the path.

It was:

.DS_Store

The original macOS working directory contained the metadata file. The fresh cleanup environment did not.

Every substantive file I cared about retained the same checksum.

This was a small but useful distinction between:

  • the invariant failing; and
  • the measurement including something outside the intended invariant.

A failed test does not automatically mean the transformation is wrong. Sometimes it means the test universe was poorly defined.

Of course, it would be .DS_Store. macOS likes to attend meetings to which nobody invited it.

I did not treat a successful commit as successful cleanup

At this stage, the rewritten local main looked correct.

But “looks correct” was exactly the kind of statement I wanted to avoid.

I therefore divided verification into several independent questions.

Does the target path still have reachable history?

git log main --oneline -- "$TARGET"

Expected result:

[no output]

Does the path appear in any object reachable from the rewritten branch?

git rev-list --objects main |
grep -F "$TARGET" ||
echo "GOOD: target path absent from reachable main history"

Do identifying references remain in reachable historical content?

git grep -I -n \
  -E 'target-file\.md|OLD_IDENTIFIER' \
  $(git rev-list main) 2>/dev/null ||
echo "GOOD: old references absent"

Do old descriptions remain in commit metadata?

git log main --format='%H %s%n%b' |
grep -Ei 'OLD_IDENTIFIER' ||
echo "GOOD: old commit metadata absent"

Did unrelated files change?

diff -u \
  /tmp/files-before.sha256 \
  /tmp/files-after.sha256

Does another ref still reach the old commits?

git for-each-ref \
  --contains "$FIRST_AFFECTED" \
  --format='%(refname)'

This last question was particularly important.

A commit can disappear from main while remaining reachable from:

  • another local branch;
  • a tag;
  • a remote-tracking branch;
  • another remote ref.

So “not on main” and “unreachable from the repository’s refs” are not equivalent claims.

Deletion started turning into a set of predicates

At this point I realized that I was already using several incompatible meanings of the word “deleted.”

For example:

Claim Possible test
The file is absent from the working tree test ! -e
The path is absent from current main git ls-tree
The path is absent from reachable history git log, git rev-list
No branch or tag reaches an old commit git for-each-ref --contains
A fresh clone does not obtain the old history clone and inspect
The local object database no longer contains an object git cat-file
The hosting service no longer resolves an old object ID remote/API query
No backend storage contains any copy generally outside normal user observability

These statements are related, but none automatically proves all the others.

This became the system-design heart of the problem.

Deletion was not a Boolean.

It was a sequence of state transitions.

I updated only the remote branch that actually needed rewriting

Before the remote update, the rewritten local main was clean while the remote-tracking reference still represented the old GitHub history.

Because inspection showed that I did not need to replace every remote ref, I avoided a broader mirror operation.

I used:

git push --force-with-lease origin main

The distinction between:

git push --force origin main

and:

git push --force-with-lease origin main

is important.

A plain force push says, approximately:

Make the remote branch point here regardless of where it currently points.

A force push with a lease adds a precondition:

Replace the remote branch only if its current state still matches the state I believe I am replacing.

If somebody or something had updated the branch after my inspection, I wanted the operation to fail.

This is a useful system-design principle far beyond Git:

Destructive mutation becomes safer when the mutation carries an assertion about the state it expects to replace.

Databases call related ideas optimistic concurrency control. HTTP has conditional requests and entity tags. Git gives me --force-with-lease.

Different systems, same design instinct: do not destroy state that changed behind your back.

The remote branch was clean

After the push I refreshed the local view of the remote:

git fetch --prune origin

Then I inspected the remote branch directly through the hosting API.

Using generic repository names here:

gh api \
  repos/example-owner/example-repository/commits/main \
  --jq '.sha + "  " + .commit.message'

The remote main now pointed to the rewritten history.

The target path was absent from normal history.

A fresh clone no longer received the old commit chain.

By most ordinary definitions of Git cleanup, I was finished.

Then I tried something else.

At this point, the ordinary Git cleanup was already finished

This distinction matters. By this stage, I had already accomplished what I would normally consider a successful repository-history cleanup:

  • the target file was absent from the current branch;
  • the historical commits containing it were no longer ancestors of main;
  • the relevant old references had been removed;
  • the rewritten branch had been pushed successfully;
  • and a normal fresh clone would receive the rewritten history rather than the old one.

For ordinary repository maintenance, I could reasonably have stopped here. That point is important because everything that followed should not be interpreted as a recommendation that every removed file requires progressively more destructive treatment. The Git problem, in the practical sense, had been solved. The cleanup itself ended before the investigation did. I still had the identifiers of the old commits. And that gave me one more test.

I queried the old commit IDs directly

I asked the hosting API whether the old commits could still be resolved:

for sha in \
  OLD_SHA_1 \
  OLD_SHA_2 \
  OLD_SHA_3 \
  OLD_SHA_4
do
    if gh api \
      "repos/example-owner/example-repository/commits/$sha" \
      >/dev/null 2>&1
    then
        echo "OLD SHA STILL RESOLVES: $sha"
    else
        echo "OLD SHA DOES NOT RESOLVE: $sha"
    fi
done

They still resolved. This was the point where my mental model changed again. The history rewrite had succeeded. The old commits were no longer ancestors of main. Fresh clones did not receive them. Normal repository navigation no longer exposed them. But the remote service still possessed enough information to return an old commit when I supplied its exact object identifier. So:

not reachable

did not imply:

does not exist

Or, stated more precisely:

not reachable through the current Git ref graph

did not imply:

not addressable through the hosting service

That qualification matters. I was not examining GitHub’s physical disks. I had not gained access to an internal object store. But I had demonstrated something stronger than merely speculating that some lower-level copy might remain:

The hosting service still retained sufficient state to recognize and return that exact historical object when I addressed it directly.

The interesting part was not persistence itself

The fact that logical deletion can precede physical reclamation is completely normal in computing. Filesystems do it. Databases do it. Garbage-collected runtimes do it. Object stores do it. Distributed caches do it. Backup systems are practically built around the idea. For example, deleting a file from a filesystem usually removes the logical path by which normal applications reach it. That does not necessarily mean every underlying storage cell has been physically overwritten at the same instant. A database can logically delete a row while older representations still temporarily participate in pages, transaction logs, replicas, snapshots or backups. A garbage collector can establish:

object is unreachable

before it later establishes:

memory has been reclaimed

So this:

logical state
        !=
physical state

was not the surprising part. The surprising part was that Git had made one of those intermediate states unusually observable.

Git gave me a handle that survived the logical deletion

This was the deeper distinction. In many systems, once the high-level object has been deleted, whatever happens below the abstraction boundary becomes opaque to the ordinary user. Git was different because the old object still had a stable identifier. The SHA survived the movement of the branch. That meant I could still ask a very precise question:

Do you still know THIS exact historical object?

and receive an observable answer. The SHA had become an epistemic handle: a surviving handle through which I could test a lifecycle state that would normally disappear behind a provider boundary. That made something normally hidden suddenly experimentally observable.

A Git SHA is more than a location in history

This follows naturally from Git’s design. A branch name such as main is movable:

main
 |
 v
C4

and later:

main
 |
 v
C9

But a commit identifier refers to a particular Git commit object. Conceptually:

branch name
    =
where the live history currently points


commit ID
    =
which exact historical state I mean

This distinction is extremely useful. It allows exact revisions to be referenced in bug reports, code review, automation, documentation and permanent links even though branches themselves continue moving. GitHub could theoretically impose another policy:

if commit is no longer reachable from an approved live ref:
    disable all direct SHA access immediately

That would simplify one meaning of deletion. But it would also couple addressability to current branch topology. A force-push, branch deletion or rebase could then immediately destroy the usefulness of historical links even while the underlying object still existed. I cannot claim that this reproduces GitHub’s internal design reasoning. I was not in those design meetings. But architecturally the trade-off is clear:

stable historical object identity
        |
        +--> reproducibility
        +--> exact references
        +--> permanent revision links
        +--> independent addressability
        |
        +--> an old object may remain queryable
             after current refs stop reaching it

And in my case, a feature designed for precise historical reference became a probe for deletion.

A feature for permanence became an instrument for observing disappearance

There is a nice irony here. The SHA exists to answer:

Which exact historical state do you mean?

After rewriting the history, it also allowed me to ask:

Does the service still know that exact historical state?

So a mechanism designed to make history durable and precisely referable also made the incomplete disappearance of rewritten history unusually observable. That is much more interesting to me than the generic statement that “deleted data might remain somewhere.” That generic statement is true of almost every sufficiently complex storage system. What I had instead was an experiment:

live Git graph says:
    no path reaches old commit

direct object query says:
    old commit still resolves

Those were two different observable states.

Why this felt different from deleting a VPS

Comparing the experience with a virtual private server helped me understand why the Git case felt unusual. Imagine a VPS stack:

me
 |
 v
root inside VPS
 |
 v
virtual disk
 |
 v
hypervisor
 |
 v
provider storage
 |
 v
physical infrastructure

I may have root inside the VPS, but I do not administer the underlying hypervisor or the provider’s physical storage systems. If I destroy the VPS, lower-level states may theoretically still exist. The provider may use snapshots. There may be replicas. Blocks may await reclamation. Backups may exist under some retention policy. Encrypted storage may be made inaccessible by destroying a key. Or the provider may use some completely different implementation. The important point is that once the VPS disappears from my account, I normally lose the handle with which I could investigate its exact former state. I cannot usually ask:

Do you still possess the exact virtual disk state
that belonged to this deleted VM at 10:43 yesterday?

Even if lower-level representations theoretically remain, their lifecycle has become epistemically opaque to me. Git was different. I had:

OLD_SHA_1

That identifier remained meaningful after main moved elsewhere. So the deletion lifecycle looked more like:

commit reachable
        |
        v
history rewritten
        |
        v
commit unreachable from live refs
        |
        v
old SHA still available
        |
        v
direct query still succeeds

The important difference was therefore not primarily physical. It was epistemic.

Git left me with an observational handle after logical deletion.

Reachability and existence are different dimensions

This distinction is fundamental to Git. Suppose I have:

A <- B <- C <- D <- main

and I move main back:

A <- B <- main

     C <- D

If no reference points to C or D, those commits become unreachable from the normal ref graph. But their objects do not necessarily vanish immediately from an object database. Git deliberately separates logical reachability from object reclamation. This makes sense. Immediate destruction would make recovery from accidental resets and branch deletions much harder. Local Git even has mechanisms such as reflogs precisely because users occasionally perform an operation with complete confidence and discover shortly afterward that complete confidence was not among the operation’s technical prerequisites. An object first becoming unreachable and an object later becoming eligible for reclamation are separate states. Hosted Git introduces another layer. I control my branches and tags. I do not directly control the host’s object storage, garbage-collection schedule, caches, recovery mechanisms or backend representations.

That is where deletion crossed a system boundary.

Local garbage collection cannot garbage-collect someone else’s infrastructure

Locally, Git has maintenance operations such as:

git reflog expire --expire=now --all
git gc --prune=now

Used carefully, these can make unreachable objects eligible for removal from the local repository. That works because I administer the local Git object database. But running:

git gc

on my Mac says nothing to GitHub’s storage layer. Likewise, a force push updates references. It does not mean:

DELETE THESE BYTES FROM EVERY STORAGE SYSTEM NOW

There is no such Git protocol message. A command such as:

git push --force-with-lease origin main

means, approximately:

update this remote Git reference,
subject to the lease condition

It does not mean:

physically erase every obsolete object,
cached representation,
replica,
recovery copy,
backup,
and storage-level remnant
associated with the former ref state

This distinction is obvious once stated, but easy to overlook when everything is presented to the user under one word: “repository.” There were really several systems:

my local Git repository
        |
        | Git protocol
        v
hosted Git service
        |
        v
provider-controlled object lifecycle
        |
        v
provider-controlled physical infrastructure

I had strong authority over the first. I had repository-level authority over parts of the second. I had only limited observability into the third. I had almost no direct observability into the fourth.

GitHub’s own cleanup model reflects the same boundary

This distinction is not merely philosophical. GitHub’s own documentation for history cleanup separates repository-history rewriting from provider-side cleanup. Removing references and force-pushing rewritten history is one stage. Removing cached views and running server-side garbage collection are separate provider-side operations. That separation closely matched what I had observed experimentally:

Git history successfully rewritten
        |
        v
old SHA still resolvable

The rewrite had changed the logical graph. It had not given me control over the provider’s object lifecycle.

At this point, authority and observability diverged

I found it useful to think of control and observation as two separate axes.

Layer My control My observability
Working tree High High
Local refs High High
Local Git objects High High
Remote branch refs High within permissions High
Remote object resolution Limited Partially observable
Repository lifecycle Controlled through provider operations Partially observable
Server-side garbage collection None directly Very limited
Physical replicas and backups None None directly

The especially interesting state was:

I could observe something that I could not directly control.

The old SHA still resolved. But there was no ordinary repository-owner command equivalent to:

DELETE REMOTE OBJECT OLD_SHA_1 NOW

That mismatch between authority and observability is part of what pushed the problem beyond ordinary Git maintenance.

I decided to create a new repository boundary

At that point I could have stopped. The normal branch history was clean, and ordinary clones no longer received the old objects. Everything after this point was stronger than ordinary repository maintenance required. But once the old SHA lookup had exposed the distinction between the live graph and the service’s retained object state, I wanted a boundary that was easier to reason about using mechanisms under my own control. Instead of repeatedly transforming the old Git graph, I chose a stronger structural separation: create a new repository from the desired filesystem state without carrying over the old .git directory at all. The important operation was not Git. It was a plain file copy:

OLD='old-working-copy'
NEW='new-working-copy'

rm -rf "$NEW"
mkdir "$NEW"

rsync -a \
  --exclude='.git' \
  --exclude='.DS_Store' \
  "$OLD"/ \
  "$NEW"/

Then:

test ! -e "$NEW/.git" \
  && echo "GOOD: no previous Git database copied" \
  || echo "STOP: .git exists"

This gave me a different kind of guarantee. I was no longer rewriting the old object graph. I was constructing a new graph from a filesystem snapshot.

A Git-free copy is conceptually very powerful

Consider the two strategies. History rewriting:

old Git graph
     |
transform
     |
     v
rewritten Git graph

Clean-room reconstruction:

old Git graph
     |
checkout current files
     |
discard Git metadata
     |
     v
plain filesystem
     |
git init
     |
     v
new Git graph

The second strategy inserts a deliberately non-Git boundary into the process. That boundary is easy to inspect:

find . -name .git -print

Expected:

[no output]

I could also enumerate the complete filesystem:

find . -type f | sort

Then verify that the target file was absent:

find . -type f -name 'target-file.md'

And search for any old path references I wanted excluded:

grep -Rni \
  -E 'target-file\.md|OLD_IDENTIFIER' \
  . ||
echo "GOOD: old references absent"

Only after verifying the plain filesystem did I create a new Git repository.

The Git-free filesystem became a trust boundary

The two approaches ask slightly different questions. History rewriting asks:

Did I correctly transform this existing historical graph?

The clean-room approach asks:

Are these exactly the files from which I want to construct a new graph?

The second question was easier to bound. I could see every file. I could verify the absence of .git. I could search the current contents. I could checksum them. And only after those checks passed would Git history exist again. In database terms, this felt less like another in-place migration and more like a sanitized export/import across an explicit trust boundary.

The new repository began with one root commit

Inside the clean directory:

git init
git branch -M main
git add -A
git status --short

Then:

git commit -m "Initial repository"

The resulting history contained exactly one root commit:

NEW_ROOT_SHA  Initial repository

This was stronger evidence than another successful filtering pass. A root commit has no parent:

NEW_ROOT

rather than:

OLD_A <- OLD_B <- NEW_C

There was simply no ancestry path connecting the new repository to the old object graph. The old commit IDs were not “hidden somewhere earlier” in the new history. There was no earlier history.

The new root commit became a structural proof

This is an example of something I increasingly like in system design: choose a structure whose desired property is easy to verify. If I repeatedly rewrite an old graph, proving that every unwanted relationship has disappeared can become complicated. If I instead construct:

ROOT

then one useful claim becomes almost trivial:

This graph has no ancestry before this commit.

That does not prove anything about a completely different old repository living elsewhere. But it gives a very strong and simple statement about the replacement repository itself.

I created the replacement remote only after local verification

Once the new repository was structurally clean, I created a new private remote:

gh repo create \
  example-owner/example-repository-new \
  --private \
  --source=. \
  --remote=origin \
  --push

Then I verified the repository independently:

gh repo view \
  example-owner/example-repository-new \
  --json nameWithOwner,isPrivate \
  --jq '[.nameWithOwner,.isPrivate]'

I also tested the old commit identifiers against the new repository:

for sha in \
  OLD_SHA_1 \
  OLD_SHA_2 \
  OLD_SHA_3 \
  OLD_SHA_4
do
    if gh api \
      "repos/example-owner/example-repository-new/commits/$sha" \
      >/dev/null 2>&1
    then
        echo "UNEXPECTED: old SHA resolves in new repository"
    else
        echo "GOOD: old SHA absent from new repository"
    fi
done

The old identifiers did not resolve there. This was expected, but expectation is not verification.

The order of destructive operations mattered

I did not remove the original remote repository first. The sequence was deliberately asymmetric:

old repository still exists
        |
        v
construct Git-free copy
        |
        v
inspect filesystem
        |
        v
initialize new Git repository
        |
        v
verify root commit
        |
        v
create new remote
        |
        v
verify new remote
        |
        v
only then retire old repository

This is a useful general pattern for destructive migrations. Do not destroy the source merely because the destination is expected to work. First prove that the destination actually works. Only then cross the irreversible boundary. In distributed-systems terminology, this is not a formal transaction, but the design instinct is transactional: prepare the successor state before releasing the predecessor. “It should be fine” is not a particularly sophisticated rollback strategy.

After deleting the original repository, the API behavior changed again

Before retiring the original repository, its old commit IDs could still be resolved through repository-scoped API requests. After removing the repository, I checked the old endpoint:

if gh api \
  repos/example-owner/example-repository-old \
  >/dev/null 2>&1
then
    echo "old repository still resolves"
else
    echo "old repository no longer resolves"
fi

The old repository no longer resolved through that interface. The application-level state had therefore changed:

before

repository exists
    +
old SHA supplied
    =
old commit resolves


after

repository no longer resolves
    +
old SHA supplied through repository route
    =
request does not resolve

That is meaningful evidence. But again, I had to be precise about what it proved.

A 404 is evidence about an interface, not a microscope into storage

If an API returns:

404 Not Found

I can conclude that the tested API route no longer exposes the requested resource to me under those conditions. I cannot conclude:

Every byte associated with this object has been physically overwritten on every server, replica, cache, backup and recovery system.

Those are entirely different claims. This became one of the most important philosophical corrections in my thinking. Engineers frequently make claims at the level of the interface while speaking as though they were claims about physical reality. For example:

HTTP 404

is an interface observation.

the information physically exists nowhere

is a global storage claim. The second requires much stronger evidence than the first.

The repository restore window revealed another intermediate state

Repository deletion itself made the model even more interesting. GitHub currently documents that eligible deleted repositories can generally be restored within 90 days, with some exceptions such as certain fork-network situations. That means these two statements can, for part of the repository lifecycle, both be true:

repository has been deleted from normal use

and:

repository remains recoverable

This is an important distinction. It implies that removing a repository from the active user-facing namespace cannot necessarily mean instantly destroying every representation required to reconstruct it. Some sufficient recoverable state must remain under the provider’s control during the applicable restoration lifecycle. I deliberately say:

sufficient recoverable state

rather than:

every original byte remains
on exactly the same physical disks
for exactly 90 days

I have no evidence for the latter. The provider could use snapshots, replicas, Git object storage, packed representations, internal backups, storage indirection or some other implementation entirely. The exact implementation is unnecessary for the conceptual conclusion. The restoration capability itself establishes:

not available normally

does not imply:

irrecoverably erased

Recovery and deletion are competing requirements

I do not think this is a defect. It is actually a good example of two legitimate system requirements pulling in opposite directions. When somebody deletes the wrong repository, they may desperately want:

DELETE
   |
   v
please let me undo that

But the strongest possible interpretation of deletion would be:

DELETE
   |
   v
make reconstruction impossible immediately

A system cannot perfectly provide both semantics at the same instant. If deletion is immediately and irreversibly destructive, recovery from accidental deletion becomes impossible. If recovery is guaranteed for a period, then deletion must initially represent a lifecycle state that is different from irreversible destruction. So a button labelled “Delete repository” may correspond internally to something more like:

remove from active namespace
        +
disable ordinary access
        +
enter provider-managed recovery lifecycle

The simple user-interface verb conceals a richer state transition.

This is where GitHub and the VPS comparison diverge again

A VPS provider may have an equally complicated internal deletion lifecycle. But unless the provider exposes restoration or some other post-deletion identifier, I usually cannot observe it. After destroying a VPS:

VM visible
    |
    v
VM deleted
    |
    v
customer handle disappears
    |
    v
provider storage state = opaque

With Git and GitHub, I encountered several distinguishable states:

commit live
    |
    v
commit unreachable
    |
    v
commit still addressable by SHA
    |
    v
repository deleted
    |
    v
repository no longer normally addressable
    |
    v
repository potentially still recoverable
    |
    v
provider physical state

That is why the Git case became so educational. It was not necessarily physically stranger than other cloud infrastructure. It was more observable.

Observability changes what can count as proof

This may be the deepest design point. A system can contain many internal lifecycle states. If its interface exposes only:

EXISTS
DELETED

then those are effectively the only states available to the ordinary user’s reasoning. But Git and GitHub expose more:

working tree
refs
commit graph
object IDs
direct SHA resolution
fresh-clone behavior
repository lifecycle
restoration capability

That richer observability allows stronger and more precise claims. For a destroyed VPS, perhaps my strongest self-verifiable statement is:

The VM is no longer accessible through my account.

For Git I could say:

The commit is no longer reachable from main, but this exact SHA still resolves through the repository API.

That is a fundamentally richer observation. Observability does not merely make debugging easier. It changes the kinds of propositions a user can experimentally establish.

Visibility, reachability, addressability and recoverability are different properties

By this point, even the distinction between “reachable” and “existing” felt too coarse. I found at least five useful properties:

visibility
reachability
addressability
recoverability
physical retention

They are related, but they are not interchangeable.

Property Question
Visibility Does the normal user interface show it?
Reachability Can the live object graph lead to it?
Addressability If I know its identifier, can I request it directly?
Recoverability Can the system reconstruct it after ordinary deletion?
Physical retention Does some underlying representation still exist inside infrastructure?

An ordinary live commit might be:

visible?              yes
reachable?            yes
addressable?          yes
recoverable?          yes
physically retained?  yes

An old commit after my rewrite was closer to:

visible normally?     no
reachable from main?  no
addressable by SHA?   yes
recoverable?          at least at the service layer
physical retention?  implementation not directly observable

After deleting the old repository:

visible normally?     no
reachable normally?   no
addressable through tested repository route?
                      no
recoverable during documented window?
                      potentially yes
physical retention?  not directly observable

So:

not visible
        !=
not reachable

not reachable
        !=
not addressable

not addressable
        !=
not recoverable

not recoverable by me
        !=
physically nonexistent

Restoration also reveals different meanings of “the same”

The restoration model raises another subtle systems point. If a Git commit is restored as that same Git commit, its object identity remains the same. But this does not require the entire surrounding platform state or underlying physical representation to be identical. For example, repository-level permissions can have different restoration semantics from Git objects themselves. Likewise, the restored data could theoretically live on different storage devices, replicas or object packs while still representing the same logical Git commits. So:

same Git object identity
        !=
same complete platform state
        !=
same physical storage arrangement

This is another familiar system-design principle:

Identity at one abstraction layer does not require identity at the layer underneath it.

A virtual machine can remain logically the same after migration to a different physical host. A database record can represent the same logical values after being rewritten into different pages. A Git commit can remain the same Git commit even if the provider stores its bytes differently internally.

I started thinking of deletion as a state machine

The word “deleted” was becoming less and less useful by itself. A more accurate model looked something like this:

PRESENT_IN_CURRENT_TREE
        |
        v
REMOVED_FROM_CURRENT_TREE
        |
        v
ABSENT_FROM_CURRENT_BRANCH
        |
        v
ABSENT_FROM_REACHABLE_BRANCH_HISTORY
        |
        v
UNREACHABLE_FROM_ALL_KNOWN_REFS
        |
        v
ABSENT_FROM_NORMAL FRESH CLONES
        |
        v
OLD OBJECT MAY STILL BE DIRECTLY ADDRESSABLE
        |
        v
ABSENT_FROM_NEW REPOSITORY GRAPH
        |
        v
OLD REPOSITORY INTERFACE NOT RESOLVABLE
        |
        v
REPOSITORY MAY STILL BE RECOVERABLE
        |
        v
BEYOND NORMAL USER RESTORATION
        |
        v
PROVIDER STORAGE STATE

Not every deletion passes through every state. Some systems expose fewer of them. Some systems expose more. And some states may exist internally without ever being observable to the user. That is exactly the point.

Deletion is relative to an abstraction boundary

This became the conceptual center of the whole exercise. At the filesystem abstraction:

path absent

may be enough to say that the file is deleted. At the Git branch abstraction:

file absent from HEAD

may be enough. At the historical Git abstraction:

path absent from reachable history

is stronger. At the object-service abstraction:

old SHA no longer resolves

is stronger again. At the provider-recovery abstraction:

repository no longer recoverable

describes another boundary. And at the physical infrastructure layer, an ordinary user may no longer have an observation mechanism at all. So “deleted” is never quite floating in space. It is always implicitly:

deleted from this layer, according to this interface, under this authority model.

This is not really a Git-specific problem

Git made the distinction unusually visible because its object model is explicit. But the same issue appears throughout modern systems.

Databases

A deleted row may still appear in:

  • write-ahead logs;
  • replicas;
  • snapshots;
  • point-in-time recovery archives;
  • change-data-capture streams;
  • backups.

Object storage

Deleting an object may create a delete marker while older versions continue to exist under versioning or retention policies.

Container registries

A tag can disappear while the referenced layers continue to exist because another manifest still references them.

Content delivery networks

The origin can change before every cached representation expires.

Search systems

A document can disappear from the primary database before asynchronous indexing pipelines remove it from every searchable index.

Virtual machines

A guest can disappear from a customer’s account while the lower storage lifecycle remains entirely under provider control.

Distributed storage

Replication exists specifically to prevent a single failure from destroying information accidentally. So the broader tension is:

Systems designed for durability naturally make complete deletion more complicated to define.

Persistence and deletion are architecturally asymmetric

Writing something into a durable system is often one operation. For Git:

git push

After that, the information may participate in:

  • local object databases;
  • remote object databases;
  • branch histories;
  • reflogs;
  • other clones;
  • cached commit views;
  • provider recovery infrastructure;
  • other references that I may not initially have considered.

Deletion must then reason about those layers individually. This is an architectural asymmetry:

creation
   |
   v
one convenient interface
   |
   v
many durable representations

whereas:

deletion
   |
   v
which layer?
which reference?
which copy?
which authority?
which recovery policy?
which observable evidence?

“Delete” therefore looks like one verb in a user interface while behaving like a distributed lifecycle underneath.

Recovery is not the enemy of deletion

The recovery window helped me see another design trade-off. Durability, recoverability and immediate irreversibility are not automatically compatible goals.

durability
recoverability
irreversibility

If I accidentally destroy an important repository, I am very grateful that a provider does not instantly and irreversibly erase every recoverable representation. If I am thinking exclusively about irreversible deletion, the very same recovery mechanism can look like retention. Both interpretations are true from different requirement perspectives. The same system property that protects me from accidental destruction makes deliberate disappearance less instantaneous. That is not inconsistency. It is a design trade-off.

Negative claims require a defined universe

There was another epistemological lesson hidden in the verification work. It is difficult to prove statements of the form:

X does not exist.

Unless I first define where I looked. For example:

The target path does not appear anywhere in main‘s reachable history.

is testable because the search universe is finite:

git rev-list main

Similarly:

No local branch or tag reaches this commit.

can be tested against the repository’s refs. And:

The old SHA does not resolve through this repository API.

is testable through the relevant interface. But:

No copy of this object exists anywhere.

has an undefined and potentially inaccessible universe. That claim cannot be established merely by running more grep. This is why I now prefer statements shaped like:

  • absent from the current tree;
  • absent from commits reachable from main;
  • unreachable from all refs in this repository;
  • absent from this fresh clone;
  • not resolvable through this API endpoint;
  • not present in this newly initialized repository;
  • not normally recoverable through this provider mechanism.

Each claim carries its own boundary. That makes the evidence much more useful.

The boundary of observability belongs in the architecture

Architecture diagrams usually show components:

client
  |
Git
  |
GitHub
  |
storage

But for operational reasoning, I increasingly think diagrams should also show observability boundaries:

LOCAL SYSTEM
------------------------------------------------
working tree            directly observable
index                   directly observable
refs                    directly observable
Git object database     directly observable


REMOTE GIT SERVICE
------------------------------------------------
repository refs         observable through Git/API
commit resolution       observable through API
fresh-clone contents    observable through Git
repository lifecycle    partly observable
restore capability      documented / partly observable


PROVIDER INFRASTRUCTURE
------------------------------------------------
internal object stores  not directly observable
replicas                not directly observable
backup topology         not directly observable
internal GC             not directly observable
physical media state    not directly observable

That boundary determines what kind of guarantee I can responsibly give. It is easy to design procedures as though every state in a distributed system were queryable. They are not. Sometimes the final state of a workflow is legitimately:

verified as far as available interfaces permit

That is not a failure. It is an accurate description of the system boundary.

Deletion is really about authority as much as data

This led me to another way of thinking about the problem. Every stage involved a different authority:

Layer Who controls it?
Working directory Me
Local Git refs Me
Local Git objects Me
Remote branch refs Me, through hosting permissions
Remote repository lifecycle Me, within provider controls
Remote object retention The provider
Provider physical infrastructure The provider
Independent clones elsewhere Their owners

A deletion guarantee can therefore be no stronger than my authority over the relevant copies and my ability to observe the states I am claiming. This seems obvious, but it has a useful consequence:

Data lifecycle is partly an ownership graph and partly an observability graph.

Once data crosses a system boundary, “delete” becomes a coordination problem.

The workflow kept changing because the evidence changed

Looking back, the procedure was not one predetermined sequence. It developed iteratively:

  1. I removed a file that no longer belonged in the repository.
  2. I realized ordinary deletion would leave historical versions.
  3. I prepared to use git-filter-repo.
  4. I inspected the graph before running it.
  5. I discovered the affected commits formed a small contiguous suffix.
  6. I chose a narrower soft-reset reconstruction instead.
  7. I protected unrelated files with SHA-256 invariants.
  8. I verified paths, content, metadata and ref reachability independently.
  9. I updated only main with --force-with-lease.
  10. At that point, the ordinary Git cleanup was complete.
  11. I queried the old commit IDs directly.
  12. I discovered that the old SHAs still resolved through the hosting service.
  13. I separated reachability from addressability.
  14. I realized that Git’s stable object identity had made a normally hidden deletion state observable.
  15. I separated local Git authority from provider-side object lifecycle.
  16. I compared this with systems such as VPS infrastructure, where deleting the customer-facing resource usually removes the observational handle.
  17. I constructed a Git-free filesystem copy.
  18. I initialized a completely new repository with one root commit.
  19. I verified the replacement remote before retiring the original repository.
  20. Deleting the old repository caused its normal API route to stop resolving.
  21. The documented restore window then separated normal accessibility from recoverability.
  22. I stopped making stronger claims once the remaining physical state became externally unobservable.

This is perhaps the part I find most representative of real engineering. The final workflow looks orderly when written retrospectively. It was not produced by knowing every answer in advance. It emerged from repeatedly asking:

What does the current evidence allow me to claim, and what should I test next?

The AI was useful mostly as a hypothesis generator

AI was involved heavily in the process, but I think the useful division of labour is worth stating precisely. The AI could reason about possible Git states, suggest commands, compare alternatives and help interpret surprising output. For example, the recommendation changed as new information appeared:

unknown history topology
        |
        v
git-filter-repo looks appropriate


small contiguous affected suffix discovered
        |
        v
soft reset becomes simpler


remote old SHAs still resolve
        |
        v
reachability model is insufficient


new boundary desired
        |
        v
Git-free copy + new root commit


repository disappears from API
        |
        v
interface deletion observed


restore window considered
        |
        v
addressability, recoverability and physical retention
must be modeled separately

But none of those recommendations should be confused with verification. The actual repository supplied the truth. git diff told me what changed. git status told me the state of the index and working tree. shasum told me whether protected files remained byte-for-byte identical. git log told me whether the target path remained reachable. git for-each-ref told me which references still contained a commit. The GitHub API told me whether remote object identifiers still resolved. The new root commit told me that the replacement repository did not inherit the old Git ancestry. And the provider’s documented restoration semantics told me that repository deletion and irrecoverability were not necessarily the same lifecycle state. AI helped generate and revise the model. Deterministic tools produced the observations. The documentation established provider-visible semantics.

The decision about acceptable residual uncertainty remained mine.

If I automated this now, I would automate the invariants more than the deletion

The tempting design would be one command:

./delete-file-completely.sh path/to/file

I am no longer sure that would be a good abstraction. The word completely hides almost every interesting question. A safer tool would probably behave more like a staged transaction.

Phase 1 — Discovery

identify target path
enumerate commits touching it
enumerate refs containing affected commits
identify unrelated changes in the same range

Phase 2 — Preconditions

working tree clean
expected remote configured
expected branch checked out
affected paths exactly match expected scope
protected-file hashes recorded

Phase 3 — Local transformation

perform rewrite
do not touch remote

Phase 4 — Verification

target path absent
target identifiers absent
unexpected paths absent
protected hashes identical
old refs unreachable

Phase 5 — Publication

force-with-lease only required branch

Phase 6 — Remote verification

inspect remote main
test old object IDs
fresh clone
compare expected state

Phase 7 — Optional clean boundary

Git-free filesystem export
new repository
new root
verify
retire predecessor

The automation should aggressively stop on ambiguity. For example:

set -euo pipefail

: "${TARGET:?TARGET is required}"
: "${BASE:?BASE is required}"

git rev-parse --is-inside-work-tree >/dev/null

test -n "$(git remote get-url origin)" ||
{
    echo "STOP: origin is missing"
    exit 1
}

And before a destructive mutation:

if [ "$ACTUAL_PATHS" != "$EXPECTED_PATHS" ]; then
    echo "STOP: repository topology differs from plan"
    exit 1
fi

I would rather have an automation refuse a legitimate cleanup than confidently perform the wrong cleanup.

I would automate claims rather than “complete deletion”

After this experience, I think the most useful deletion tool would report bounded postconditions rather than one Boolean result. For example:

[PASS] absent from working tree
[PASS] absent from current branch
[PASS] absent from reachable history
[PASS] no known refs contain old commits
[PASS] fresh clone clean
[FAIL] old SHA still remotely addressable
[PASS] old SHA absent from replacement repository
[PASS] old repository no longer resolves normally
[INFO] repository may remain within provider recovery lifecycle
[UNKNOWN] provider physical storage state

This is far more informative than:

DELETED = true

The tool should model observable predicates, not declare metaphysical victory.

System design is partly the art of deciding what counts as evidence

The most interesting part of this experience was ultimately not a particular Git command. It was learning to associate every claim with an observation. If I say:

The file is gone from the branch.

I should be able to show a branch-level test. If I say:

The old commits are unreachable.

I should be able to show a ref-reachability test. If I say:

The old object is still directly addressable.

I should be able to show the successful SHA lookup. If I say:

The new repository contains none of the old ancestry.

I should be able to show its root topology. If I say:

The hosting API no longer exposes the old repository.

I should be able to show the API result. If I say:

The repository remains potentially recoverable during the provider’s documented recovery period.

that claim should come from the provider’s documented lifecycle. And if I cannot inspect the provider’s physical storage, then I should not silently upgrade an interface observation into a physical-erasure claim. This restraint is not merely linguistic caution. It is part of system design.

Git is unusually honest about the difficulty

In some ways, I appreciate Git more after this. Git does not pretend that removing a filename from the latest tree rewrites the past. Its object model makes persistence explicit. A commit ID is not simply a database row number. It identifies a particular content and ancestry structure. If I change the past, Git changes the IDs. If a commit becomes unreachable, Git distinguishes that state from immediate object reclamation. If I construct a new root commit, the topology visibly records that this is a new history. The model may be initially unintuitive, but it is internally coherent. The difficult part was that I initially used the ordinary-language word “delete” across several layers that Git carefully keeps separate.

The original maintenance task was simple; defining success was not

By the end, I could make several precise statements. I could verify that:

  • the target file was absent from the current tree;
  • the target path was absent from the rewritten reachable history;
  • the affected old commits were no longer ancestors of main;
  • unrelated repository files remained byte-for-byte unchanged;
  • no relevant branch or tag retained the old history;
  • a fresh clone did not contain the old branch history;
  • the replacement repository began from a new root commit;
  • the replacement repository did not resolve the old commit identifiers;
  • the retired repository eventually stopped resolving through the tested user-facing API route.

I could also distinguish facts that were no longer under my direct authority:

  • the provider may maintain a restoration lifecycle after repository deletion;
  • repository recoverability is not equivalent to ordinary repository visibility;
  • physical retention below the provider boundary is not directly inspectable by an ordinary repository owner.

That last distinction prevents all the previous evidence from being stretched beyond what it actually demonstrates.

What I would do differently next time

Several practical habits came out of this. First, I would use disposable clones even more aggressively for history work. If an experimental rewrite becomes confusing, deleting the temporary clone and starting again from a known state is often easier than proving that a half-modified repository is still safe. Second, I would package shell variables and assertions into one script rather than maintaining procedural state across multiple Terminal windows. Third, I would create integrity snapshots before destructive operations by default, not only when something feels risky. Fourth, I would inspect the commit graph before choosing a rewriting tool. Fifth, I would distinguish local verification, ref verification, clone verification, direct-object verification and provider-interface verification in the procedure itself. And finally, I would define the desired deletion state before starting. For some tasks:

absent from HEAD

is enough. For others:

absent from reachable history

may be the requirement. For still others:

not directly addressable through the hosting service

may matter. And rebuilding a repository from a Git-free snapshot can provide a much simpler ancestry boundary when that is useful. The correct operation depends on the required postcondition.

Deleting in Git changed how I think about deletion in general

The original task was unremarkable: remove a file that no longer belonged in a repository. The practical Git cleanup succeeded before the most interesting part of the investigation began. What stayed with me was the accidental visibility of the states underneath it. A file can be absent while its historical commit remains reachable. A commit can become unreachable while the service can still address it by SHA. An object can disappear from normal clones while still being directly resolvable. A repository can stop being normally accessible while remaining potentially recoverable. And an interface can stop exposing something without giving the user direct knowledge of the physical storage layer underneath. Git gave the old history stable identities. Those identities survived the movement of the live refs. Because the hosting service could still resolve them, I could experimentally distinguish reachability from addressability.

Deleting the repository then distinguished ordinary addressability from recoverability. And the provider boundary finally separated recoverability from whatever physical storage state existed underneath. So I no longer think of deletion as:

exists = false

I think of it more like:

deletion = {
    visibility,
    current_state,
    historical_reachability,
    reference_reachability,
    addressability,
    object_availability,
    recoverability,
    replication_state,
    authority_boundary,
    observability_boundary,
    verification_scope
}

That may look excessive for deleting one file. But the same model appears in databases, filesystems, cloud storage, virtual machines, caches, backups, distributed logs and almost every system designed to remember things reliably. What made Git unusually educational was not that it somehow invented residual data. It was that its content-addressed object model left me with a stable handle after logical deletion. That handle made something normally hidden partially observable. And once an intermediate state becomes observable, it stops being merely a theoretical implementation detail. It becomes something that can be tested, reasoned about and incorporated into the system model. Durability is a feature. Recoverability is a feature. Stable historical identity is a feature. Each of those features also makes the semantics of deletion richer than a Boolean. Perhaps that was the deepest lesson from the whole exercise:

Deletion is relative to an abstraction boundary.

At one boundary, an object may already be gone. At another, it may still be reachable. At another, no longer reachable but still addressable. At another, no longer addressable but still recoverable. And below the final observable boundary, there may be states that belong entirely to someone else’s infrastructure. So before asking whether something has been deleted, I now want to ask more precise questions:

Deleted from which layer? Reachable through which graph? Addressable through which interface? Recoverable by whom? Under whose authority? Observable from where? And proven by what?