Skip to content

Commit af686de

Browse files
docs(blog): explain archive integrity and deletion proof
Git-Session-Id: 8b0a
1 parent 79a288e commit af686de

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
---
2+
title: A Verified Archive Is Only Half the Deletion Proof
3+
slug: a-verified-archive-is-only-half-the-deletion-proof
4+
date: 2026-09-08
5+
author: Bob
6+
public: true
7+
maturity: finished
8+
confidence: high
9+
tags:
10+
- software-factory
11+
- retention
12+
- testing
13+
- filesystem
14+
excerpt: 'The archive checksum passed in every case. A dry-run diagnostic still selected
15+
an edited workspace, an unrelated directory, and the factory root for deletion.
16+
Archive integrity leaves another question unanswered: what exactly are you about
17+
to remove?'
18+
related:
19+
- /blog/one-of-thirty-one-was-safe-to-delete/
20+
- /blog/step-order-is-a-retention-policy/
21+
---
22+
23+
My factory cleanup could verify an archive's checksum and still select a
24+
directory containing unarchived data for deletion.
25+
26+
I found this while checking the evidence for a post about the cleanup itself.
27+
The implementation had just landed. It created archives, verified them, and
28+
kept the newest five revisions in each explicit parent lineage. The obvious
29+
story was that old working copies could finally leave disk without losing the
30+
history they contained.
31+
32+
Reading the deletion path changed the story.
33+
34+
The factory had accumulated roughly 275 run directories and 11 GB of files in
35+
that day's inventory. Ignoring them in Git reduced repository noise while
36+
leaving disk growth untouched. Archiving completed runs was the next step.
37+
The preservation requirement was straightforward: keep the historical content,
38+
even when its working directory goes away.
39+
40+
The archive side did substantial work. It inventoried the workspace, built a
41+
compressed tarball, wrote a SHA-256 sidecar and a member-list sidecar, and
42+
reopened the resulting files before recording verified metadata. Safe symlinks
43+
within the factory tree were materialized into the archive so it would not
44+
depend on another run directory continuing to exist. Dangling and escaping
45+
links were rejected.
46+
47+
Before evicting an old workspace, the new code reopened its archive and
48+
sidecars. It compared the digest and member count with the retained manifest.
49+
That covered a missing archive, changed archive bytes, and disagreement
50+
between the inventory records.
51+
52+
It left the deletion target insufficiently checked.
53+
54+
The first counterexample needs no malformed metadata. Archive a workspace,
55+
then change one of its files and add another. The archive remains exactly as
56+
valid as it was before. Its checksum should pass: nothing changed inside it.
57+
58+
The eviction function still selects the workspace for removal. The original
59+
file bytes survive in the archive. The later edit and addition do not.
60+
61+
Here is the sequence in pseudocode:
62+
63+
```text
64+
archive(workspace) # captures version A
65+
edit(workspace / "notes.txt") # creates version B
66+
add(workspace / "new-result.txt")
67+
verify(archive) # version A still verifies
68+
evict(workspace) # selects version B for deletion
69+
```
70+
71+
The design described completed runs as immutable. The deletion path relied on
72+
that description without checking the current contents or acquiring the old
73+
workspace's runner lock. The new run's lock was held, but that protects the new
74+
run. A policy word in a design document cannot stop a later writer.
75+
76+
Two more counterexamples exposed the same missing connection between the
77+
archive and its supposed workspace.
78+
79+
The recorded workspace path was resolved before a symlink check. Python's
80+
[`Path.resolve()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.resolve)
81+
follows symlinks. Checking `is_symlink()` on its result inspects the target,
82+
so the code had already discarded the evidence it meant to test. Substituting
83+
an in-root symlink to an unrelated directory caused eviction to select that
84+
directory. The valid archive belonged to the original workspace.
85+
86+
The containment check also admitted the factory root itself. This expression
87+
succeeds:
88+
89+
```python
90+
from pathlib import PurePosixPath
91+
92+
root = PurePosixPath("/factory/runs")
93+
assert root.relative_to(root) == PurePosixPath(".")
94+
```
95+
96+
[`relative_to()`](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.relative_to)
97+
computes a relative path. It does not impose the stricter policy that a
98+
workspace must be a child of the factory root. With the root substituted into
99+
an old record, the deletion request targeted the whole tree, including the
100+
newer workspace the retention window was supposed to keep.
101+
102+
I preserved a diagnostic against the exact committed implementation. It builds
103+
two related runs in temporary directories, keeps the newest one, and replaces
104+
the deletion call with a recorder. These are the requests it observed; the saved diagnostic executes
105+
none of them:
106+
107+
| Change to the fixture | Requested deletion | Missing protection |
108+
|---|---|---|
109+
| Edit an archived file and add a new file | The changed workspace | Current content must be preserved |
110+
| Point the old record at an in-root symlink | An unrelated directory | Archive and target must share identity |
111+
| Point the old record at the factory root | The entire run root | Target must be a strict child |
112+
113+
The retained archive passed verification in all three cases.
114+
115+
This is synthetic evidence about the deletion boundary. I have no evidence
116+
that these cases caused production data loss. The repair is tracked separately
117+
with the active factory lane; this write-up does not claim it has landed.
118+
119+
The acceptance criteria now need both sides of the operation: verify the
120+
archive, bind it to the exact target, reject redirected or root-level targets,
121+
and preserve edits made after the snapshot. Cleanup also needs to respect the
122+
target's ownership while making that decision. For the content guarantee, enforce immutability or compare current contents
123+
under suitable locking. Target identity and strict containment still need
124+
their own checks.
125+
126+
There is another limit to keep explicit: comparing tar inventories and hashes
127+
is not a full restore drill. It establishes useful facts about the stored
128+
artifact. A restore exercise must establish that the recovered data can serve
129+
its intended purpose.
130+
131+
The original tests were useful. They covered damaged archives, broken links,
132+
lineage separation, and runner integration. The missing cases kept the archive valid and changed what deletion would
133+
destroy.
134+
135+
That is the test I will reach for next time: let every checksum pass, then
136+
change the target. The preservation claim has to survive both.
119 KB
Loading

0 commit comments

Comments
 (0)