# A deleted file isn't gone. It's waiting to be overwritten

> Source: <https://dev.to/merouane_flame_56f/a-deleted-file-isnt-gone-its-waiting-to-be-overwritten-22dg>
> Published: 2026-10-27 18:09:11+00:00

*This is a submission for the [Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine](https://dev.to/challenges/hacktoberfest-week4-2026-10-26)*

The letter is 208 bytes, and it is fictional. But the situation isn't.

I built a 128 MB ext4 disk image, wrote the letter into it, and deleted it at 07:34 on a Tuesday morning. Then I tried to recover it.

The filesystem still knew the letter existed. The directory entry survived. The inode survived. Even the deletion timestamp survived. `icat` can copy the bytes out. Any classical recovery tool — `photorec`, `scalpel`, `foremost` — will hand the letter back, perfectly, and then stop.

Because the next question is not a filesystem question.

What else went with it? Which of these eight photographs was in the box? What was the file whose name survived but whose blocks did not? A recovery report that lists only what it recovered is lying by omission.

That gap is **Palimpsest**. It recovers deleted files from a disk image and then explains what they were, using open-weight models running entirely on your own machine. It looks at the photographs, groups the files by meaning, writes a labelled hypothesis for the ones that cannot be recovered, and composes an epitaph for each artifact in which **every sentence carries the id of the evidence it came from**.

It is not a chatbot for your files. It is a witness statement with a chain of custody.

**[The sample case, rendered](https://flameeecyber.github.io/palimpsest/demo/)** — nine recovered files, two hollow tombstones, and the evidence table behind every sentence. Everything on that page was generated on this laptop from a synthetic disk image, and the four commands below reproduce it:

./scripts/make_sample_image.sh          # builds a 128 MB ext4 image, no root, no mount

palimpsest run sample/ghost.img --out out

palimpsest ablate --out out

palimpsest verify --out out

`out/site/index.html` is a static site: no server, no JavaScript, no CDN, no webfont. Copy it to a USB stick and it still opens.

*The rendered case. The two dashed stones at the bottom are files that provably existed and did not come back.*

Recovery first, because the story depends on it. No root, no loop device, no mount — the image is built with `mkfs.ext4` and `debugfs`, so it's reproducible in CI and can't damage the machine it runs on:

fls -r -d -p image      # every deleted entry, recursively

istat image      # the ext4 "Deleted:" line — when the file died

icat image       # the bytes, if the blocks are still free

One detail matters: `istat` gives the **deletion time**, not the modification time — the difference between *when this was last written* and *when someone got rid of it*. And when `icat` returns zero bytes, the artifact is kept as **missing**, with its name, size, parent directory and deletion time. It is not dropped from the report.

From there, five model stages:

**1. Triage.** Gemma 3 looks at the pixels of a recovered image and returns subject, setting, people count, visible text, and a human label. A carved file has no name and no EXIF, so the pixels are the only evidence. On this CPU, one cold vision call takes 200–700 seconds, so triage is capped at the most promising images — a scheduling decision, not a filter that hides failure.

**2. Link.** Every artifact is rendered as a line of text, embedded locally with `nomic-embed-text`, and clustered with union-find over a cosine graph. No random seeds, no hidden parameters. The model names each group the way a person labels a box of belongings — `C1: Summer Family Photos`, `C2: Sunset Landscapes June`.

**3. Infer.** For a file whose blocks are gone, the model gets only facts: name, size, deletion time, directory, neighbours. It returns a one-sentence hypothesis. The UI renders it as a dashed tombstone labelled `hypothesis`, never as a recovered file. `photorec` reports an unrecoverable file as a failure. Palimpsest reports the shape of the hole.

**4. Narrate.** Two to four sentences per artifact, each citing evidence ids. Then the validator runs.

**5. Query.** `palimpsest ask` embeds your question, retrieves the ten nearest artifacts, and answers with artifact citations — then checks the answer the same way the epitaphs are checked.

The first version of the validator checked that every cited evidence id existed. That is the obvious check, and it is not enough.

`gemma3:1b` wrote:

"The image depicts a sunset over the sea." — citing `E1: IMG_2019_0006.png`

Every word is plausible. None of it came from the evidence. A citation-existence check published it.

So the second check computes a **support score**: the fraction of the sentence's content words that actually appear in the cited values, after removing function words and generic visual nouns. Below `0.2`, the sentence is struck. Years get a stricter rule: a year in the sentence must appear verbatim in a cited value.

Struck sentences are never published. They appear on the detail page crossed out, with the reason — because the failure is evidence too.

*The same letter, narrated by `gemma3:1b` instead of `gemma3:4b`. One sentence survives with its source at 100%; the other quoted the letter but cited the wrong row, so it is struck on the page with the reason. A forensic report that silently deletes its failures is not a forensic report.*

Then the loop feeds the rejection back to the model, once, with the exact strike reason. The best attempt wins. That retry loop is the difference between a demo and something that holds up when the model is having a bad day.

I want to be precise about what this is: **lexical overlap is a tripwire, not entailment.** A fluent sentence can reuse the right nouns and still be wrong. The defence is layered — the prompt forbids invented relations, the support score catches the crude cases, `verify` re-checks later, and the UI labels speculation differently. It is not a proof. It is a documented, reproducible filter.

The obvious next feature was a chat box. `palimpsest ask` embeds the question, takes the cosine top-k over the case, and asks for two to four sentences with artifact ids attached.

The first answer it produced was this:

The recovered photographs [2f3dfa31df], [59125a46a8], [78294c9b10] and [cadaa85ef6] were all created on June 20th, 2019 with an iPhone 8.

Every id is real. Every id is a photograph from the same cluster. One of them was created on June 20th; the others were created on the 15th, the 18th and the 23rd. Retrieval did its job, the citations were correct, and the sentence was false anyway — because **a citation is not a check.**

So the answer now goes through the same machine as an epitaph. `check_answer()` splits it into sentences, requires each sentence to cite an artifact that exists, and looks for every timestamp, year, date, and quoted passage in the facts of the files that sentence cites.

The rule that catches the sentence above is the quantifier. If a sentence says *all*, *every*, *both*, *each*, *none* or *neither*, the fact has to hold for **every** artifact it cites; otherwise one is enough. *"The file [2f3dfa31df] was created on June 20th, 2019"* passes. The same date stretched over four files is struck, and the strike is fed back once, exactly like a rejected epitaph.

Struck sentences never become the answer. They are printed underneath it, crossed out with the reason. The tool shows you what it refused to say.

The unrecoverable files are always in the context, marked `unrecovered`, because they have no embedding to retrieve and they are the most interesting thing in the case:

$ palimpsest ask "Which files never came back, and what do you think they were?" --out out

The file [0343d28351] was unrecovered and probably a photograph. The file

[6e82e87c54] was also unrecovered and likely taken on a mobile device.

struck (never shown to the user):

  x  "Both files have an unknown creation date."

     -> cites no artifact

The retry is what the check buys you. Same question, same model, second attempt:

$ palimpsest ask "Which recovered photographs were taken on the same summer

trip, and what does the letter have to do with them?" --out out

The file [c3d3c9aebb] IMG_2021_0002.png was created on 2019-06-16T18:24:01.

The file [fb343c5bd6] IMG_2020_0007.png was created on 2019-06-21T18:29:01.

The file [2f3dfa31df] IMG_2019_0006.png was created on 2019-06-20T18:28:01.

The file [cb023cb531] IMG_2021_0008.png was created on 2019-06-22T18:30:01.

The letter [b81c5c8508] letter.txt mentions a box of photographs from a

summer trip to the coast.

Four true dates instead of one true date repeated four times. Fifteen minutes on this CPU for the two attempts — the honest price of a second opinion when the first one is wrong.

The claim "the AI is load-bearing" is easy to make and hard to check, so it is a command:

$ palimpsest ablate --out out

artifacts with a human label             0             2

groups found by meaning                  0             2

unrecoverable files explained            0             2

artifacts with an epitaph                9             9

sentences written                       26            18

sentences with a source                 26            18

words about the content                  0            17

The baseline is not a strawman. `stub_narrate()` was written before the model path and emits only what a `stat` call can prove: *"letter.txt is a document of 208 bytes."* It writes more sentences and says less. That is the whole argument in one table.

A forensic report is only worth something if someone else can check it later. `palimpsest run` writes `manifest.json` with the source image hash, the SHA-256 of every recovered artifact, the model names, the SHA-256 of every prompt file, and the version of every tool that touched the evidence.

`palimpsest verify` re-reads the case and checks seven things: the artifact set, every file hash, every citation target, every published sentence's support score, prompt drift, code drift, and the source image hash. Exit code 1 on any failure, so it drops straight into CI.

This is the concrete form of the reproducibility argument. A report produced by a hosted model that was silently updated last Tuesday cannot be reproduced. A report produced by a pinned digest can.

`palimpsest verify` can tell you whether the case still stands. You cannot do that with a closed model's system prompt, and you cannot do it at all if the model behind an API changes without a version number.
`nomic-embed-text` via Ollama · no network required.
