damage: files can be broken on purpose, and something has to refuse them - #95
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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):
zero-head(8 B)truncate-halfThat is why
bytesstarts 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
sha256per file. The damage knows for free instead -zero-headhas 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,avifandjxlall begin with zero bytes, so a smallzero-headon them really is idle.The
expectedcollision was settled nowhere. The regression surface says an expectation from a recipe reaches the manifest unchanged; the note saidrejectis set automatically. It is now a refusal, and only foraccept-sanitizeandunspecifiedare both sensible questions about a broken file.Where it sits
Three things fall out without being built: the checksum describes the bytes on disk, the existing
counter.n != Plan.Bytescheck counts the FINAL bytes, and progress counts what will really be there.Evidence
ok internal/guard 264.4s.journey.py300 of 300.D1parity: 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.manifest_versionstays1.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,SizeMinandSizeMaxwere always one statement, soengine.Targetandrecipe.Targetnow carry aSizeRange.⚠ That refactor invalidated six mutation entries and only
staleness.pysaid so. A pattern that no longer matches reportsSKIP, 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 targetedError(), which a recipe never calls, because a recipe reports throughWhat/Why/Instead.Known limits, written down rather than left to be found
tfg damagecommand. 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.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