Differencing phase 15: consistency for the non-composing operations¶
Prompt¶
Plan phase 15 of PLAN-differencing.md. Phase 14 made convert,
dd, compare, bench and rebase compose a differencing VHD or
VHDX against its parent. map, measure and check do not, and
this phase is about making that boundary correct and defensible
rather than about moving it.
Three things changed under these operations when phase 14 landed and
none of them were addressed by it. The composing readers now refuse
a VHDX payload block whose byte range overlaps a region the image
declares (#625); map and measure do not apply that test, so the
three operations disagree about what a valid block is (#634).
check and bench attach a backing chain without the
MAX_CHAIN_DEVICES guard every other attaching operation has
(#633). And the comments justifying the check and measure
refusals say instar cannot read a differencing image, which was true
when phase 4 wrote them and is false now.
The phase plan is the deliverable; implementation is a separate ask.
Planning effort¶
High. Not because the diff is large -- it is the smallest phase of the composition arc -- but because the scope was wrong in the master plan and the survey had to settle it, and because the central decision is a refusal this phase deliberately does not lift. A phase whose main output is "these three stay refused, and here is why that is still right" has to be argued rather than asserted, or phase 17's documentation has nothing to stand on.
Review effort: medium. The master plan does not specify one. The code is small and local; the judgement is in this document.
Scope¶
In scope.
- Issue #634:
VhdxState::map_extentsandVhdxState::scan_allocationdo not apply the block/region overlap test thatVhdxState::block_lookupandVhdxState::sector_bitmap_lookupapply, somapreports as data a block thatconvertrefuses to read. - The third answer in the same family:
checkhas its own VHDX region and BAT validation, and whether it is stricter than, laxer than, or merely different from the readers has not been measured. - Issue #633:
checkandbenchreachvmm_config_chainwith an uncapped device count, whereconvert,dd,commit,rebaseandcompareall refuse an over-deep chain up front. - The refusal justifications phase 14 falsified, in code comments
and in
docs/. - A test for the
commitdecision phase 14 made and did not test. CHANGELOG.mdand the user documentation for the three non-composing operations.
Out of scope, with the reasons stated.
- Chain composition in
map-- issue #641.maprefuses any backing pointer, for every format, and has done sinceeb6e23f(2026-06-03), which predates this plan.qemu-img mapcomposes and emits a per-extentdepth; instar's protobuf reserves the field and documents it as always0. Closing that is a format agnostic parity feature, not differencing work, and it needs multi-device plumbing in a guest op that holds noChainConfig. - Chain composition in
measure-- issue #642. Same shape.measurealready answers a qcow2 chain with the top layer's allocations alone, whichdocs/measure.md:139documents as a divergence; the plumbing it needs is identical whichever format the chain is in. - Both were deferred by plans that are now
Status: Complete(PLAN-map.md,PLAN-measure.md), so the follow-up phases they point at do not exist. The issues are the record, and whichever plan picks them up should take them together. - Lifting the
checkormeasuredifferencing refusal. Decision 2 below, and issue #643. - Fixtures -- #631 (no CLI differencing-over-differencing coverage) and #635 (no parentless differencing VHDX) are phase 16's, which is the tests and fuzz phase.
-
628 (
is no longer right) and #632 (safe-mode rebase onto a raw backing) are unrelated to this boundary.create -brefuses a differencing backing for a reason that¶
What the survey found¶
The master plan's phase 15 section was written before phases 11 to
14 executed. Two of its four claims hold, two do not, and it is
missing the thing that actually makes this phase necessary. All
references below are against 7fef1321.
Holds. "map and measure have no chain notion at all."
Neither run_map (src/vmm/src/main.rs:15756) nor run_measure
(:15161) calls discover_backing_chain, and neither guest op
mentions ChainConfig -- the five that do are bench, check,
compare, convert and rebase.
Holds. "check's validate_chain walks a chain's members to
validate each independently rather than to compose a read."
src/operations/check/src/main.rs:3281 reads sector 0 of each
member, cross-checks the detected format against the chain metadata
and the member's virtual size against zero, and bounds-checks a
qcow2 header. Nothing composes.
Wrong, and already answered. "commit is a composing caller on
the host (src/vmm/src/main.rs:7265) ... phase 15 should decide
whether that is correct or merely untested." The line reference has
drifted -- :7265 is now inside run_rebase -- and phase 14 decided
it. run_commit passes DifferencingComposition::Unsupported
(:7598) under a comment saying the guest ignores the ancestor
slots the host populates, so resolving a differencing parent could
only turn a working command into a path error. It is correct and
untested, which is the half of the master plan's question that
survives.
Wrong. "map's own chain composition is additionally deferred to
PLAN-map.md ..., so phase 15 must settle which plan owns it before
it starts." Neither candidate owner is open: PLAN-map.md and
PLAN-measure.md are both Status: Complete, each carrying this
work in Future work and each pointing at a "follow-up phase" that
was never created. The choice was never between two plans; it was
between this phase and nothing. Settled by decision 1.
Missing from the master plan entirely, and the reason this phase exists. Phase 14 tightened what the composing readers accept and left the other three behind:
block_overlaps_a_declared_region(src/crates/vhdx/src/lib.rs:2315) has exactly three callers --block_lookup's two payload arms (:2384,:2419) andsector_bitmap_lookup(:2535).map_extents(:2794) walks the BAT withclassify_vhdx_bat_entry(:2881) andscan_allocation(:2635) counts withcount_allocated_in_bat_chunk. Neither consults it.- The two gaps are not the same gap.
mapemits afile_offsetfor a Data extent, so a block declared inside the metadata region is reported to the user as a real location.scan_allocationnever looks at a file offset at all -- it counts BAT entry states -- someasure's wrong answer is a size that counts a block the reader would refuse, not a wrong location. checkis a third answer again, with its own region table cross-validation and overlap detection (src/operations/check/src/main.rs:14-18) reached only undercheck. Whether it catches what the readers refuse is not established -- 15b measures it rather than assuming.
Also missing: a measured parity baseline. The master plan asserts
nothing about what qemu-img does for these three. Measured against
qemu-img 10.0.13 (Debian 1:10.0.13+ds-0+deb13u1) on a plain qcow2
chain, base holding 1 MiB at offset 0 and child holding 1 MiB at
offset 4 MiB:
| Tool | Behaviour with a backing chain |
|---|---|
qemu-img map |
composes; depth: 1 for base-resolved extents, depth: 0 for the child's own |
qemu-img measure -O qcow2 |
composes; child measures 2424832, identical to its own flattening, against 1376256 for its data alone |
qemu-img check |
does not walk the chain; reports on the top image only |
So check needs no composition to reach parity, and map and
measure need composition that has nothing to do with differencing.
That asymmetry is what split this phase from #641 and #642.
Corrected at source. The master plan's phase 15 bullet and the
index.md row have been rewritten in this phase's first commit to
say what the survey found, so the next reader does not re-derive it.
Phase 14's Definition of done also carried an unfalsifiable
criterion -- its grep is grep -rn ... | grep -v '^\s*//', and
grep -rn prefixes path:lineno: so the filter can never match --
corrected in the same commit. The criterion passes once the prefix
is stripped: zero non-comment PLAN-*.md references in src/.
Decisions¶
-
Chain composition for
mapandmeasureleaves this plan, as issues #641 and #642, rather than becoming phase 15. The master plan's success criterion for these operations is that "no instar op silently composes a differencing image as though it had no parent ... applied uniformly acrossinfo,check,convert,compare,dd,bench,mapandmeasure", and it explicitly records thatmapalready refused before phase 4 and is "the precedent phase 4 generalises rather than outstanding work". Uniform non-silent handling is met. Composition in these two is a qcow2-chain parity feature that two Complete plans deferred, and building it for the differencing arms alone would construct the plumbing and then decline to use it where qemu-img already diverges. -
The
checkandmeasuredifferencing refusals stay. This is the decision most likely to be argued with, because the stated reason for one of them is now false.check's VHD arm (src/operations/check/src/main.rs:1996) justifies itself with "walking it and reporting 'no errors' ... tells a user an image instar cannot read is fine", and instar can read it now. The refusal nevertheless survives its own justification:checkvalidates one image's structure, and a differencing child's structure is almost always intact while the image as a whole is unusable without its parent. Reporting "No errors were found" and a child-only allocation percentage is precisely the answer phase 4 existed to prevent -- a complete-looking verdict on a partial view.qemu-img checkgives exactly that answer and is wrong to. So the comment gets corrected to the reason that is still true, not deleted, and the lift question is filed as #643 with both sides written down rather than settled here by a phase whose scope is consistency. -
mapandmeasurerefuse the malformed image rather than skipping the block.map_extentsandscan_allocationboth returnOption, soNoneis the existing failure channel and no new control flow is needed. Refusing matches what the readers do with the identical predicate, and matches phase 14's rule that the VHDX arms fail closed rather than guess. Emitting the block as unallocated would be the clamping phase 14 ruled out, and would makemapclaim an image is sparse where it is actually malformed. -
Each of the two gets a new error code rather than reusing an existing one.
MAP_RESULT_ERROR_INVALID_SOURCErenders as "map: source format unrecognised" andMEASURE_RESULT_ERROR_INVALID_SIZEas "source image is unsupported format". Both would be false: the format is recognised and the image parsed far enough to walk its BAT. A user shown "unrecognised" will go looking for a format problem that is not there. -
#633 is fixed for
benchas well ascheck, in one step.benchis not this phase's operation, but it is the same missing guard in the same shape and the issue covers both. Fixing one and leaving the other would close nothing and leave the next reader to rediscover the asymmetry. The repository convention is to sweep the class. -
The half-lifted tree is this plan's end state, stated deliberately. Five operations compose a differencing source and three decline it. Phase 14's risk register called that "a new user-visible inconsistency", with testing the refusal messages as the mitigation. This phase makes it a documented boundary with a reason per operation, and phase 17 presents it as a boundary rather than a gap. Decisions 1 and 2 are what make it defensible; if either is reversed the boundary moves and phase 17's framing changes with it.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 15a | high | opus | none | Close the map and measure half of issue #634. VhdxState::block_lookup (src/crates/vhdx/src/lib.rs:2384, :2419) and VhdxState::sector_bitmap_lookup (:2535) refuse a block whose byte range overlaps a region the image's region table declares, via block_overlaps_a_declared_region (:2315). VhdxState::map_extents (:2794) and VhdxState::scan_allocation (:2635) do not, so map reports as data a block convert refuses to read. Apply the same predicate in both, refusing rather than skipping (decision 3): map_extents classifies each entry with classify_vhdx_bat_entry at :2881 and must test the offset of any entry it is about to emit with a file_offset; scan_allocation counts through count_allocated_in_bat_chunk and never forms a file offset at all, so it needs the offset extracted for each present block before it is counted. Return None -- both functions already use it as their failure channel. Add one new error code to each operation rather than reusing an existing one (decision 4): the host's constants are at src/vmm/src/main.rs:169-172 for map and :148-154 for measure, rendered by map_error_message (:16165) and the match inside run_measure (:15735). Word both messages for a recognised image with a malformed block table, not an unrecognised format. Crate tests for a payload block overlapping the metadata region, one overlapping the BAT region, and a positive control immediately past the last declared region, mirroring the ones phase 14 added for the reader arms; plus tools/mutate-differencing.sh cases reverting each of the two new call sites. Do not touch the zero-offset guard (MIN_BLOCK_FILE_OFFSET) -- it is a separate, independently killable check and phase 14 deliberately kept it so. |
| 15b | high | opus | none | Settle the third answer. check has its own VHDX region table 1+2 cross-validation, metadata parsing, BAT walk and overlap detection (src/operations/check/src/main.rs:14-18), reached only under check and using a scratch bitmap (BitmapContext::init_in_scratch, see :749 and the region marking at :756) rather than the reader's predicate. Measure, do not assume: build a VHDX whose BAT names a payload block overlapping the metadata region -- the phase 14 crate tests in src/crates/qcow2/src/lib.rs show how to patch one in place, and VhdxState::init deliberately skips region-table CRC validation so an entry can be edited without a checksum fixup, though check validates the CRC so this fixture needs it recomputed -- and record what each of convert, map, measure and check says about it, before and after 15a. If check already flags it, the deliverable is the measurement plus a test pinning it, and say so plainly. If check misses it, make it a counted corruption rather than a refusal: an image whose BAT points into its own metadata is malformed, which is exactly what check exists to report, and exit 2 is the right answer where the readers' exit 1 is not. State the four-way table in the commit message -- it is the evidence for phase 17's claim that the operations now agree. |
| 15c | medium | sonnet | none | Fix issue #633. bench (src/vmm/src/main.rs:5696) and check (:12316) reach vmm_config_chain(sector_size, chain.total_devices()) with an uncapped device count; every other attaching operation refuses an over-deep chain first -- convert/dd at :13679, commit at :7613, rebase at :7255, compare at :12826. Note the issue's line numbers for the two call sites have drifted by four and five lines since it was filed; the numbers here are against 7fef1321. Add the same up-front guard to both (decision 5), wording each message in that operation's own voice as the existing four do, and refusing before KVM is opened rather than after. The downstream disagreement the issue describes is the reason this matters: write_chain_device_entries (:3449) stops at MAX_CHAIN_DEVICES while the count handed to vmm_config_chain does not, so the guest is told about more devices than ChainConfig describes. Test both operations with a chain of 17 at the CLI level and assert the message, not just a non-zero exit. src/shared/src/lib.rs:665 asserts VQ_BASE_START + 16 * 0x10000 <= DMA_POOL_BASE, so a 17th device's virtqueue lands on DMA_POOL_BASE -- say in the commit message whether you reproduced that before the fix, because "the guard was missing" and "the guard was missing and it corrupted memory" are different changelog entries. |
| 15d | medium | sonnet | none | Correct the justifications phase 14 falsified, without changing what any of them do (decision 2). Three comments assert instar cannot read a differencing image: src/operations/check/src/main.rs:1981-1995 (the VHD arm's "tells a user an image instar cannot read is fine"), the VHDX arm above it at :1591, and src/operations/measure/src/main.rs around the two refuse_differencing call sites at :422 and :441. Five operations read one now. Replace the premise with the one that survives -- check and measure report on one image, and a differencing child's own structure is intact while the image is unusable without its parent, so a clean verdict or an allocation figure would describe a partial view as though it were whole. Do the same sweep over docs/: grep for claims that instar cannot compose a differencing source, flattening whitespace first because these sentences wrap mid-phrase and plain grep misses them -- phase 14 found four files beyond the ones its plan named that way. Do not reword any user-visible refusal message: phase 14 settled that text, tests/test_differencing.py pins it, and this step is about reasons rather than behaviour. No production behaviour changes; the proof is the existing differencing suite still passing unmodified. |
| 15e | medium | sonnet | none | Test the commit decision phase 14 made and did not test. run_commit (src/vmm/src/main.rs:7598) passes DifferencingComposition::Unsupported to discover_backing_chain because the commit guest op reads the overlay and the backing and ignores the ancestor slots the host fills in. commit refuses any overlay that is not qcow2 or vmdk (:7418, "format '{other}' does not support commit"), so a differencing VHD or VHDX can only ever appear further back in the backing's own ancestor chain. Build that: a qcow2 overlay over a qcow2 backing whose own parent is a real differencing VHD from instar-testdata, commit the overlay, and assert both that it succeeds and that the committed contents are right -- an assertion on the exit code alone would pass if the ancestor had been resolved and silently composed. Add a second case with the differencing ancestor's own parent moved aside, which must still succeed: that is the specific thing decision-by-Unsupported buys, and a regression to Supported would turn it into a path error. Put both in tests/test_differencing.py beside TestDifferencingNonComposingRefusalPolicy, and add a tools/mutate-differencing.sh case flipping :7598 to Supported so the decision is held by something that fails. |
| 15f | low | sonnet | none | Documentation and changelog. CHANGELOG.md: the overlap test now reaching map and measure (a Fixed entry -- a malformed image these two used to describe is now refused), the check/bench depth guard, and whichever of 15b's outcomes happened. docs/map.md, docs/measure.md and docs/check.md each need the new refusal described beside their existing differencing section, and docs/map.md:210 and docs/measure.md:139 should be checked to confirm they still read as current limitations rather than as plans now that #641 and #642 carry the follow-up -- link the issues. docs/quirks.md carries the per-operation differencing table and must state the boundary as deliberate (decision 6): five compose, three report on one image, with the reason per operation. docs/chain-config.md's Per-Operation Usage table gains nothing unless 15c changes a device count. Do not touch ARCHITECTURE.md -- no component or data path changes -- and do not touch AGENTS.md: nothing here is a convention an agent could not infer from the code. Check what each page currently claims rather than assuming; phase 14 rewrote several of these and a stale claim here would be two phases old. |
Risks and mitigations¶
-
15a tightens a read path for every VHDX image, not only a differencing one.
map_extentsandscan_allocationrun whateverhas_parentsays, so an image thatmapdescribed yesterday can be refused today. This is the same surprise phase 14's review round 1 caught in 14c, where the row claimed "not a behaviour change" and was wrong. Mitigation: it goes in the changelog as a Fixed entry that names the behaviour change, a crate test pins the refusal on ahas_parent == falseimage so the dynamic path cannot regress unnoticed, and the management session checks that no fixture ininstar-testdatatrips it before the step is accepted. -
15b may find
checkis already right, and the step then looks like nothing happened. Mitigation: the measurement is the deliverable, stated as a four-way table in the commit message. A step that establishes three operations already agree has earned its place, and phase 17 cannot claim agreement without it. -
Decision 2 is the one a reviewer will push on. Keeping a refusal whose written reason is false reads like inertia. Mitigation: the reason is replaced rather than patched, the counter-argument is filed as #643 rather than lost, and the success criterion it rests on is quoted in decision 1 rather than paraphrased.
-
A phase that mostly says "no" can quietly skip the work it does own. The three code steps are small and the temptation is to fold them into the documentation step. Mitigation: 15a, 15b and 15c each close or measure a numbered issue and each carry a test; none of them is complete because the docs describe it.
-
The repository's standing warning. Agents in this project assert plausible-but-wrong format and tool capabilities. Every claim in 15b about what
checkvalidates must come from running it against a built image, not from reading the module header that lists what it is supposed to validate.
Definition of done¶
instar mapandinstar measurerefuse a VHDX whose BAT names a payload block overlapping a declared region, with a message that does not say the format is unrecognised. Falsifiable: the same image is refused byconvert,mapandmeasure, and the three messages are in 15a's commit message.- The positive control passes: a payload block immediately past the last declared region is still mapped and still measured. A high-water-mark implementation would fail this and an overlap test passes it.
grep -c 'block_overlaps_a_declared_region(' src/crates/vhdx/src/lib.rsreturns 6 -- one definition and five call sites, up from the three call sites today.- A four-way table for one malformed image -- what
convert,map,measureandcheckeach say -- is in 15b's commit message, with thecheckrow measured against a built binary. instar checkandinstar benchrefuse a 17-image chain before opening KVM, each naming its own operation. Falsifiable: a test asserts the message text for both, andgrep -c 'exceeds maximum of' src/vmm/src/main.rshas grown by two, from 3 to 5.
Search string corrected at close-out. As written this criterion
grepped for exceeds the, which neither guard 15c added says: both
render "chain depth {n} exceeds maximum of {max} devices", matching
the convert/dd and compare guards rather than the rebase and
commit ones that do say "exceeds the {n}-device limit". The
exceeds the count was 5 before the phase and 5 after, so the
criterion as phrased could not have been met by any wording 15c
plausibly chose. Both guards exist, both refuse before KVM opens and
both name their own operation (src/vmm/src/main.rs:5389 for
bench, :12094 for check), and the corrected string does grow
by two -- so what was wrong here was the probe, not the work it was
measuring.
* Nothing in src/, tests/ or the top level of docs/ claims that
instar cannot read or compose a differencing image. Falsifiable
by a script rather than a grep:
python3 tools/check-no-capability-claims.py, exit 0.
Its own docstring records why it is not a grep -- the sentences wrap
mid-phrase, and a line-granular grep missed the one in
src/shared/src/lib.rs reading "Instar cannot / compose a parent
yet" across a line break. The script is verified by mutation, not
only by passing: restoring that wrapped claim makes it exit 1 and
name the line.
Widened during review. As first written this criterion said "no
comment in src/", and the script read only // comments there. The
first review round asked whether that was enough; widening it to
paragraphs across src/, tests/ and docs/ immediately found a
live claim in a tests/test_differencing.py assertion message, which
the comment-granular form could not see. The criterion is recorded in
its widened form because the narrow one was satisfiable while the
claim it exists to prevent was still in the tree.
The criterion is about the global claim, not about any
per-operation statement. "This operation does not compose a
differencing chain" is true of check, measure and commit and
must survive, as must a comment or test that forbids the global
claim. An earlier draft of this criterion was a grep that matched
both, and the first implementation attempt duly reworded a true,
clear debug message into worse English to satisfy it. Code is not
bent to fit a check; a check that flags true statements is the thing
that is wrong.
Not wired into pre-commit or CI. It is a tool the way
tools/mutate-differencing.sh is, and a future contributor gets it
only by running it.
commitsucceeds over a backing whose ancestor is a differencing VHD, and still succeeds when that differencing ancestor's own parent is absent. Both asserted intests/test_differencing.py, and a mutation flippingrun_committoDifferencingComposition::Supportedkills the second.tools/mutate-differencing.shcase count has grown, the count is derived bycheck_case_countrather than written in prose, anddocs/testing.mdagrees with it. Every new case is PASS or a documented SURVIVOR; no BROKEN.make test-rustpasses with zero failures and the count is stated, against the 2450 phase 14 measured.pre-commit run --all-filesis clean.- Nothing this phase adds cites a plan file, phase, step or decision
number, and no user-visible string contains
PLAN-. The check spanstests/andtools/as well assrc/: an earlier draft of this criterion covered onlysrc/, and a test docstring added by 15e duly cited "phase 14" where the criterion could not see it.
git diff 7fef1321..HEAD -- 'src/*' 'tests/*' 'tools/*' \
| grep '^+' \
| grep -iE 'PLAN-[a-z0-9-]+\.md|decision [0-9]|phase 1[0-9]|\b15[a-f]\b'
The step-letter alternative needs its word boundaries: without
them it matches 15e9 inside the BAT region GUID a 15b test
spells out in hex.
is empty. docs/plans/ is deliberately out of scope -- plan files
cite each other by design.
tests/test_differencing.pycites no plan file, phase or decision number at all, not merely none added by this phase. Falsifiable:grep -cE 'Decision [0-9]|decision [0-9]|PLAN-[a-z0-9-]+\.md|phase 1[0-9]|phase plan' tests/test_differencing.pyreturns 0.
This probe was too narrow, found during phase 16. It returns 0,
and six plan coordinates were in the file the whole time: "step 4b"
twice, "phase 4", "phase 7" and two bare "this phase". phase 1[0-9]
matches phases 10 to 19 only, and no alternative covered a step
letter. The criterion's claim -- "at all, not merely none added by
this phase" -- was therefore never tested. Phase 16 swept the file
for the whole class and widened the pattern in its own definition of
done; the figure of four pre-existing references this criterion
mentions was itself an undercount for the same reason.
Four such references predated this phase, one of which -- "whether
instar assembles a chain the way libvhdi does is phase 15's
question" -- this phase's own rescoping made false, so correcting
them is this phase's business rather than tidying.
* docs/quirks.md states the five-compose / three-report boundary
with a reason per operation, and #641, #642 and #643 are linked
from the pages that describe the limitation they would close.
Back brief¶
Before implementation starts, confirm:
-
Decision 2, before any code moves. Everything else in this phase is small and reversible; keeping the
checkandmeasurerefusals is the call that shapes phase 17 and closes out this plan's user-visible story. If it is wrong, it is wrong now, and 15d and 15f both rest on it. -
Decision 1 and the two issues already filed. #641 and #642 are written as hand-offs, with the measured qemu-img baseline in them, on the assumption that a later plan takes both together. If phase 15 is meant to absorb either after all, say so before 15a: the step plan is five steps smaller than it would otherwise be.
-
Step order. 15a and 15b are the same family and 15b's measurement is more useful after 15a has moved
mapandmeasure, so it reads the end state rather than a moving one. 15c is independent and can run at any point. 15d must follow 15b, since whatcheckdoes is part of what its comment should say. -
Model and effort. 15a and 15b are opus at high effort: one changes a predicate's reach across every VHDX read, the other has to measure rather than reason. 15c, 15d and 15e are sonnet at medium with the briefs above. 15f is low. No step wants
fable. -
The standing warning. Measure, do not reason, about what a VHDX BAT contains at a given geometry and about what
checkvalidates. Phase 13 found its largest defect exactly where the arithmetic looked obviously right.