|
| 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. |
0 commit comments