Skip to content

damage: files can be broken on purpose, and something has to refuse them - #95

Merged
donislawdev merged 1 commit into
mainfrom
damage/stage-1
Sep 9, 2026
Merged

damage: files can be broken on purpose, and something has to refuse them#95
donislawdev merged 1 commit into
mainfrom
damage/stage-1

Conversation

@donislawdev

@donislawdev donislawdev commented Sep 9, 2026

Copy link
Copy Markdown
Owner

The tool answered questions about size and about name. It did not answer the third question an upload validator asks - does it open - because every file it wrote was well formed by definition.

targets:
  - id: broken-uploads
    format: png
    size: 20kb
    damage: [zero-head]
tfg generate --format png --size 20kb --damage zero-head:bytes=16

The file still comes out exactly the size asked for. The manifest records what was done and says the file is expected to be rejected.

Three things the architecture note got wrong

Each changed the design, not only the prose. All three were found by measuring rather than by reading.

A witness is not a property of the pair. It is a function of (format, damage, size, parameter values):

20 kB 300 B 4 B
zero-head (8 B) 24/24 9/9 2/2
truncate-half 22/24 8/9 0/2
bytes zeroed witnesses of 24
1 20
2 20
4 24

That is why bytes starts at 4. The bound belongs to somebody else's reader, the same rule the column ceiling followed.

"The comparison is free because we compute the checksum anyway" was false. The write path computes ONE checksum, so before-and-after would cost a second sha256 per file. The damage knows for free instead - zero-head has to read the bytes before it overwrites them. So the damage answers, not the engine, and it answers per step because two damages can cancel out.

Reachable rather than theoretical: ico, avif and jxl all begin with zero bytes, so a small zero-head on them really is idle.

The expected collision was settled nowhere. The regression surface says an expectation from a recipe reaches the manifest unchanged; the note said reject is set automatically. It is now a refusal, and only for accept - sanitize and unspecified are both sensible questions about a broken file.

Where it sits

generator -> DAMAGE -> counter -> MultiWriter( buffered -> file, h )

Three things fall out without being built: the checksum describes the bytes on disk, the existing counter.n != Plan.Bytes check counts the FINAL bytes, and progress counts what will really be there.

Evidence

  • 16 guards, 17 mutations, all caught. The one worth naming is the reverse of every other oracle guard here: it asks whether a judge REFUSES what we wrote, and its control runs first, because without it the test passes for a build whose judge refuses everything.
  • Full suite green, ok internal/guard 264.4s. journey.py 300 of 300.
  • D1 parity: the not-reachable list went from 11 to 10 rather than growing - the window offers the menu, draws the parameters from the declaration, and a guard presses it and reads the bytes back.
  • A run with no damage is untouched: the manifest key is absent rather than empty, and manifest_version stays 1.0.

What this cost, and it is worth reading

Three types would have grown past the crowding band, and that count only goes down. Two of them became better shapes anyway: SizeIsRange, SizeMin and SizeMax were always one statement, so engine.Target and recipe.Target now carry a SizeRange.

That refactor invalidated six mutation entries and only staleness.py said so. A pattern that no longer matches reports SKIP, which reads as proven. Re-targeted and re-run: 20 entries across 12 guards, all still caught.

One of my own new mutations came back NOT CAUGHT, and it was a statement about the mutation rather than the code - it targeted Error(), which a recipe never calls, because a recipe reports through What/Why/Instead.

Known limits, written down rather than left to be found

  • No tfg damage command. The first version of the flag help pointed at one, which was a promise of something that does not exist - the help now lists the names from the registry instead. The real asymmetry stays: the window draws a damage's parameters, the command line shows only names. First thing for stage 2.
  • The window takes one damage. The chain carries more and a recipe writes more.
  • truncate, the flipped bit, format-aware damage and archive members are all out of stage 1, with reasons in the document.

Analysis, measurements and everything not checked: docs/CORRUPTION-ARCHITECTURE-2026-09-08.md, sections 13 and 14.

🤖 Generated with Claude Code

The tool answered questions about size and about name. It did not answer the
third question an upload validator asks - "does it open" - because every file
it wrote was well formed by definition.

A target now takes damage, as a list from the first day because composition is
a requirement rather than an extension:

    damage: [zero-head]
    damage: [{type: zero-head, bytes: 16}]

and on the command line --damage zero-head:bytes=16, repeatable and applied in
the order given. The file still comes out exactly the size asked for. The
manifest records what was done to it and says the file is expected to be
rejected.

zero-head is the one damage in this build. It was chosen because all twenty
four formats have a judge that refuses the result and because it does not
change the length, so it does not touch the size arithmetic.

Three things the architecture note said turned out to be wrong, and each
changed the design rather than only the prose.

A witness is not a property of the pair (format, damage). It is a function of
the format, the damage, the SIZE and the parameter VALUES. Measured:
truncate-half has witnesses at 20 kB and none at 4 B, and zeroing one byte has
20 witnesses of 24 where four bytes have all of them. That is why the bytes
parameter starts at 4 - the bound belongs to somebody else's reader, the same
rule the column ceiling followed.

"The comparison is free because we compute the checksum anyway" was false. The
write path computes ONE checksum, so before-and-after would cost a second
sha256 per file. The damage knows for free instead: zero-head has to read the
bytes before it overwrites them. So the damage answers whether it moved
anything, not the engine - and it answers per step, because two damages can
cancel out. Measured that this is reachable rather than theoretical: ico, avif
and jxl all begin with zero bytes.

The collision between an automatic expectation and one written in a recipe was
settled nowhere. It is now a refusal, and only for accept: reject is what
damage means, while sanitize and unspecified are both sensible questions about
a broken file.

Damage sits between the generator and the counter, which gives three things
without building them: the checksum describes the bytes on disk, the existing
size check counts the FINAL bytes, and progress counts what will really be
there.

Nineteen guards, nineteen mutations, all caught. The one worth naming is the
reverse of every other oracle guard here - it asks whether a judge REFUSES what
we wrote, and its control runs first, because without it the test passes for a
build whose judge refuses everything.

Three types moved state out rather than growing past the crowding band, and one
of them turned out to be a better shape anyway: SizeIsRange, SizeMin and SizeMax
were always one statement, so engine.Target and recipe.Target now carry a
SizeRange. That refactor invalidated six mutation entries and only staleness.py
said so - a pattern that no longer matches reports SKIP, which reads as proven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 18a9487 into main Sep 9, 2026
18 checks passed
@donislawdev
donislawdev deleted the damage/stage-1 branch September 9, 2026 09:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant