Skip to content

feat(tiff): build a metadata seam, then carry the C2PA manifest store - #520

Open
justin13888 wants to merge 45 commits into
feat/442-dng-c2pa-manifest-storefrom
feat/446-tiff-metadata-seam
Open

justin13888 wants to merge 45 commits into
feat/442-dng-c2pa-manifest-storefrom
feat/446-tiff-metadata-seam

Conversation

@justin13888

@justin13888 justin13888 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Issue #446 asks gamut-tiff to carry the C2PA manifest store, and says up front that building
the metadata seam is the bulk of the work
— the crate had no metadata read or write surface at
all. This PR does both halves, in that order.

Stacked on #508 (feat/442-dng-c2pa-manifest-store), which adds the shared gamut_ifd::c2pa
module. This PR is opened against that branch and must not merge before it. #508's head has moved
three times under review; every move is merged in here by merge commit (never rebase), most
recently 677d38e, at which point #508 is itself fully delivered. This PR has also been through
one review round of its own — see Review round 1 below.

  1. feat(tiff): carry ICC, XMP, IPTC-IIM and an Exif sub-IFDTiffMetadata
    (#[non_exhaustive], new() + with_* builders) is written into IFD 0 by
    TiffEncoder::with_metadata on the strip, tile and multi-page paths alike, and read back by
    TiffDecoder::metadata. XMP (700), IPTC-IIM (33723) and ICC (34675) are opaque bytes carried
    verbatim — the raw MetadataBlock-ready payloads gamut-png and gamut-webp hand over — and
    the ExifIFD (34665) is handed over as a gamut_ifd::Ifd, because it is a directory this
    crate has already parsed and a byte blob would force every caller to re-parse it. Nothing is
    validated or completed.
  2. feat(tiff): carry the C2PA manifest store over the shared placement rules — the store
    joins as a fifth carrier. §A.3.6 and §18.5.5 are not restated: gamut_ifd::c2pa (from
    feat(dng)!: type the C2PA manifest store and report both exclusion ranges #508) owns the tag, the last-main-chain-IFD placement, the end-of-file store, the
    reserve-then-append relocation and the locator, and gamut-dng calls the same helper, so the
    two formats cannot drift. with_c2pa_reserved(len) writes a zero-filled reservation for an
    external signer to overwrite in place; encode_with_report returns the two exclusion ranges;
    c2pa_exclusions(file) recovers them from any TIFF's bytes — including files written through
    encode_palette8 or encode_pages_rgb8, which the object-safe EncodeImage seam cannot
    report through. Tag 52545 joins is_known_tag.
  3. docs(tiff), style(tiff) and test(tiff) — STATUS.md/README.md, one readability
    fix, and robustness coverage for the two new byte-consuming entry points plus a BigTIFF store.

Semver: additive only, so minor. gamut-ifd, gamut-dng and every other crate are untouched by
this branch's own commits; the only non-gamut-tiff content here comes in through the base merges.

No human approved this plan. This is an unattended automated run; the decision record below is
what a human reads afterwards.

Validation

Every command below was run in this worktree at head 20268b8, after merging base 677d38e, and
every one of them completed. mise run fmt-check needs the __CARGO_TEST_ROOT prefix in a nested
nested worktree (cargo otherwise walks past the worktree root to the primary
checkout's tooling/* manifests and exits 101 on an untouched tree); the prefix is recorded
verbatim.

CARGO_BUILD_JOBS=2 cargo test -p gamut-tiff --all-features
  -> ok, 193 test cases passed, 0 failed

CARGO_BUILD_JOBS=2 cargo clippy -p gamut-tiff --all-targets --all-features -- -D warnings
  -> clean, no warnings

__CARGO_TEST_ROOT=<worktree> mise run fmt        -> applied
__CARGO_TEST_ROOT=<worktree> mise run fmt-check  -> exit 0
mise run check-tests                             -> exit 0
   "module docs, pinned proptest seeds and oracle filenames all conform"
convco check origin/feat/442-dng-c2pa-manifest-store..HEAD -> "no errors in 8 commits"

mise run lint     (whole workspace, capped scope)  -> exit 0
mise run test     (whole workspace, capped scope)  -> exit 0, 206 test binaries "ok", 0 failures
   (re-run after the repair, since F1 changes what the decoder returns)

GAMUT_MUTANTS_BASE=origin/feat/442-dng-c2pa-manifest-store \
  mise run mutants --diff --budget 16
  -> exit 0. "54 mutants tested in 11m: 51 caught, 3 unviable" — 0 MISSED.
     This is the gate PR CI blocks on (`--in-diff`). The stacked base is supplied through
     GAMUT_MUTANTS_BASE so the diff is this branch's own work, not #508's.

Workspace-wide commands ran inside
systemd-run --user --scope --slice=agents.slice -p MemoryMax=16G -p MemorySwapMax=0 with
CARGO_BUILD_JOBS=2 CMAKE_BUILD_PARALLEL_LEVEL=2 and ulimit -v 12000000; the mutation runner
applies its own equivalent guards from --budget 16.

mise run check-release-deps / check-ffi-features / check-ffi-header were not run and are
not applicable: no Cargo.toml changed and no public C-surface type changed.

Not run: mise run coverage (CI's own Coverage gate passed on the previous head),
check-cross, check-msrv, test-dng-real (extended CI, master/manual).

Review round 1

Four findings, all fixed in c26c851, 98c734a and 20268b8.

  • F1 (High) — a decoded Exif sub-IFD carried a dangling InteroperabilityIFD pointer.
    read_metadata asked read_tree for ExifIFD alone, and read_tree re-parses only the tags
    it is given, so an Interop pointer inside the Exif directory came back as a raw Long holding
    the source file's absolute offset, which apply then wrote into a file laid out at
    different offsets. Decode → re-encode produced a file the crate's own judge rejects:
    is_fully_classified() == false with a severity: Error "sub-IFD could not be parsed" —
    breaking gamut-tiff's v1 zero-tolerance byte accounting on a file gamut-tiff itself wrote.
    Reproduced as a failing test first, then fixed by reading with
    gamut_ifd::tags::STANDARD_POINTER_TAGS, the list the repository already provides and
    gamut-dng's rewrite path already uses.
  • F2 (Medium) — metadata() and c2pa_exclusions() disagreed, and the docstring said they
    agreed.
    The reader accepted any UNDEFINED value under 52545; locate also rejects one below
    MIN_STORE_LEN or a duplicated entry, so a short entry decoded to a TiffMetadata the encoder
    then refused. The reader now gates on the locator, as gamut-dng does, making the two agree by
    construction rather than by comment.
  • F3 (Low) — BigTIFF's true minimum reservation is 9, not 8. append_store refuses a store
    that fits inline, so the bound is max(MIN_STORE_LEN, inline_threshold + 1). A BigTIFF
    reservation of 8 compressed the whole image before failing from gamut-ifd, despite the
    # Errors promise to catch length "before any pixel work". Gated on min_store_len(); both doc
    claims corrected.
  • F4 (Low) — missing tests. The palette path is now exercised with with_metadata and a
    store, and a deconstruct assertion covers a file carrying the Exif sub-IFD — the gap F1
    escaped through.

Risks and rollout

  • Additive surface only; every existing encode path keeps its behaviour when no metadata is
    configured (TiffMetadata::apply writes nothing for an empty set).
  • The metadata blocks are out-of-line values, so they displace the pixel data's offsets. That is
    the failure this could plausibly cause, and it is pinned against libtiff rather than against
    gamut's own reader (tests/oracle_metadata.rs, three sizes, with and without a store).
  • The C2PA store is written after the image data, at the end of the file. The crate's v1
    zero-tolerance byte-accounting guarantee therefore had to keep holding: tests/c2pa.rs asserts
    deconstruct reports the store as its entry's typed Value span, with nothing unclassified, no
    Trailer segment, and no unknown tag.
  • The store is opaque and never byte-swapped (§A.3.6). The fixture is an asymmetric payload in
    a big-endian (MM) file, so a byte-swapping implementation cannot pass; a palindrome would
    have.
  • TiffDecoder::metadata and c2pa_exclusions are new entry points taking untrusted bytes on
    paths decode_page never takes (an ExifIFD pointer into a second directory; a walk to the end
    of the chain for an offset/count pair). Both are now in tests/robustness.rs under the
    truncation sweep and the 5000-mutation byte-flip corpus.
  • A decoded TiffMetadata is now safe to hand straight back to with_metadata: every pointer
    under the Exif directory is a parsed child the writer re-points, so a round trip cannot emit a
    stale offset. This is the F1 fix and is pinned by a decode → re-encode → deconstruct test.
  • TiffDecoder::metadata can now return Err on a file whose ExifIFD pointer is malformed,
    where decode_image still succeeds. Deliberate, and documented under # Errors: reporting
    exif: None for a directory the file declares is silent loss a caller cannot detect.
  • Rollback: drop the feat(tiff): carry the C2PA manifest store … commit to keep the seam alone;
    drop this branch's own commits to return to the base.

Issue

Refs #446. Not Closes — the issue's own text names c2pa-rs as the behavioural oracle, already
filed separately as #447, and this PR carries the store without validating it.

Two existing issues cover what this PR deliberately leaves out, so no new issue was filed:

Decisions taken

Issue 446 - gamut-tiff: build a metadata seam, then carry the C2PA manifest store
Plan:     v1
Branch:   feat/446-tiff-metadata-seam
Base:     head of PR #508 (origin/feat/442-dng-c2pa-manifest-store, a645321) - stacked; PR opened against that branch
Cause:    -
Touches:  gamut-tiff new metadata.rs + encoder/decoder/info/lib/tags + tests + STATUS/README
Will not: depend on gamut-metadata or any typed metadata crate (that wiring is #420's remainder, filed as #510); parse the C2PA store; byte-swap it; re-derive SS A.3.6 (the gamut-ifd module from #508 owns it); touch gamut-ifd or gamut-dng
Lane:     serialised behind E-442 (stacked)
Settled:  S2 semver - gamut-tiff gains only additive API, so minor; S3 docs/testing.md; #508's decisions 9-14 (relocation mechanism, `gamut_ifd::Range`, `locate<S: ReadAt>`, MIN_STORE_LEN, store-XOR-reservation) are inherited, not revisited

Decisions taken.
1. Deliverable boundary
   Taken:    the seam first (ICC, XMP, IPTC-IIM and the Exif sub-IFD, as RAW MetadataBlock-ready payloads), then the C2PA store over the shared gamut-ifd helper - the issue's own ordering, and the seam is what makes the C2PA slice a small addition rather than a special case
   Rejected: a C2PA-only side channel, which the issue and the epic explicitly forbid; also wiring the typed gamut-metadata facade here - that is #420's remainder (#510) and adding the dependency edge now would collide with it
   Reverses: drop the C2PA commit and keep the seam
   Filed:    -
2. Seam shape
   Taken:    `TiffMetadata { exif: Option<Ifd>, xmp: Option<Vec<u8>>, iptc: Option<Vec<u8>>, icc: Option<Vec<u8>>, c2pa: Option<Vec<u8>> }` - the Exif sub-IFD as `gamut_ifd::Ifd` (gamut-tiff already re-exports it, and a raw byte blob would force every caller to re-parse a directory the crate has already walked), everything else as opaque bytes; `TiffEncoder::with_metadata(TiffMetadata)` on the builder and the decoded surface exposing the same struct; `#[non_exhaustive]` on `TiffMetadata` so later carriers are additive, unlike DngMetadata
   Rejected: mirroring DngMetadata's exhaustive struct - #442 had to take a major for exactly that, and there is no reason to buy the same break twice
   Reverses: make the struct exhaustive, or carry Exif as bytes
3. C2PA placement
   Taken:    reuse `gamut_ifd::c2pa` verbatim for the tag, the last-main-chain-IFD placement, the end-of-file store and both exclusion ranges; `TiffEncoder::with_c2pa_reserved(len)` plus reporting through an `encode_with_report`-shaped call, leaving the object-safe `EncodeImage` untouched (the C-portability rule)
   Rejected: a second implementation of SS A.3.6 in gamut-tiff - the issue says the two must share their placement logic rather than diverge
   Reverses: inline the placement logic here
4. Tag knowledge
   Taken:    add the metadata tags this seam now genuinely reads and writes, plus 52545, to `is_known_tag`, so `deconstruct`'s tag-level verdict matches what the crate understands (note #513 tracks the same one-line gap in gamut-dng; do not fix that here)
   Reverses: revert the tags.rs edit
5. Oracle and acceptance
   Taken:    libtiff must read a file carrying the metadata tags and the store unchanged; a round-trip through gamut's own reader must return byte-identical payloads; the store is verbatim in both directions, pinned on a big-endian `MM` fixture with an ASYMMETRIC payload (a palindrome would pass a byte-swapping implementation); the two exclusion ranges are asserted disjoint; byte accounting claims the store rather than leaving it unclassified or a trailer

Appended by this lane

6. How `encode_with_report` reaches the exclusion ranges
   Taken:    `encode_with_report<P>(image, out) where Self: EncodeImage<P>` encodes through the
             existing object-safe entry and then reads the ranges back out of the bytes it just
             wrote, via the public `c2pa_exclusions(file)` - itself a thin delegation to
             `gamut_ifd::c2pa::locate`. One fact, one function: the encoder reports exactly what a
             verifier will find, and the paths a generic method cannot reach (`encode_palette8`,
             `encode_pages_rgb8`) report their store through the same call.
   Rejected: threading `append_store`'s returned ranges out through the private encode pipeline,
             which would need a new public sealed trait to dispatch over the eight `EncodeImage`
             pixel types - a large surface, and more mutants, to avoid one cheap re-parse of the
             directory chain. Also rejected: a `DngEncoder`-shaped concrete `encode_with_report`,
             which does not exist here because gamut-tiff's encode entry *is* the trait.
   Reverses: delete `c2pa_exclusions` and plumb `C2paExclusions` back through `encode_packed`,
             `encode_tiled` and `encode_pages_rgb8` behind a sealed dispatch trait.
7. The Exif sub-IFD is carried verbatim, not completed
   Taken:    the encoder writes the caller's `ExifIFD` exactly as given and the decoder returns
             exactly what the file holds. No mandatory Exif field is synthesised.
   Rejected: `gamut-dng`'s behaviour of supplying a default `ExifVersion` (36864) when absent. DNG
             does that because a DNG's `ExifIFD` is part of *its* conformance surface; a TIFF's is
             the caller's own directory, and synthesising a field would make `encode` return
             something the caller did not ask for and break the verbatim round-trip that decision 5
             makes the acceptance test.
   Reverses: synthesise `ExifVersion` in `TiffMetadata::apply`, as gamut-dng does.
8. Where metadata goes in a multi-page document
   Taken:    the XMP / IPTC / ICC blocks and the Exif sub-IFD go in IFD 0 alone (they describe the
             document, and duplicating an ICC profile per page would inflate the file); the C2PA
             entry goes in the LAST page, because SS A.3.6 puts the one store in the last IFD of
             the main chain. Pinned by `a_multipage_document_carries_the_metadata_on_page_0_only`
             and `a_multipage_document_puts_the_entry_in_its_last_page`.
   Rejected: writing the blocks into every page (duplication), or the C2PA entry into page 0
             (a spec violation SS A.3.6 states outright).
   Reverses: move the `apply`/`reserve_entry` calls in `encode_pages_rgb8`.
9. `TiffDecoder::metadata` errors rather than degrading on a broken sub-IFD pointer
   Taken:    it reads through `gamut_ifd::read_tree`, so a malformed, looping or out-of-bounds
             `ExifIFD` pointer is a typed `Error::InvalidInput`, documented under `# Errors`.
   Rejected: `gamut-dng`'s lenient `.ok()`, which would report `exif: None` for a directory the
             file really does declare - silent data loss in a metadata reader, and a verdict the
             caller cannot distinguish from "no EXIF present". Callers wanting leniency can walk
             the re-exported `read`/`read_tree` spine themselves.
   Reverses: swap `read_tree` for a per-pointer `read_ifd_at(...).ok()`.
10. The three open questions from review 1 - decided by the orchestrator on review
   Q1 metadata() on a malformed ExifIFD pointer.
   Taken:    1a, keep erroring. A metadata accessor that silently reports "no EXIF" for a
             directory the file declares is the silent-loss failure this run has rejected
             repeatedly. gamut-dng degrades because there metadata is incidental to a raw decode
             that must still succeed; that difference is now DOCUMENTED rather than eliminated,
             including the plain statement that metadata() can fail where decode_image() does not.
   Rejected: 1b, degrade to `exif: None` - indistinguishable from genuine absence.
   Reverses: swap `read_tree` for a per-pointer `read_ifd_at(...).ok()` and delete the doc note.
   Q2 where the blocks go in a multi-page document.
   Taken:    2a, IFD 0 only. An N-page file carrying N copies of an ICC profile is the worse
             outcome, and IFD 0 is where a reader conventionally looks. The cost - a reader
             decoding page 3 alone sees no profile - is now stated in the module docs, README and
             STATUS rather than left for a caller to discover.
   Rejected: 2b, replicate the blocks onto every page.
   Reverses: apply the metadata to every page in `encode_pages_rgb8`.
   Q3 what "verbatim" promises for the Exif sub-IFD.
   Taken:    3a, verbatim - nothing synthesised, nothing dropped - but the WORD is fixed. A
             directory model is not a byte range, so the docs now say "entries carried unchanged,
             ordering normalised" and name the three normalisations `gamut_ifd::Ifd` performs:
             fields sorted by ascending tag (TIFF 6.0 SS 2 requires it on disk), a duplicated tag
             collapsed to its last occurrence, and a child directory's next-IFD pointer ignored.
             A conforming source directory is unaffected by all three; a non-conforming one is
             silently repaired, which is worth knowing before using a re-encode to argue a file
             unmodified.
   Rejected: implying byte-identity, which no answer to Q3 could have delivered.
   Reverses: revert the wording in metadata.rs, README.md and STATUS.md.
   Q4 how the "refused before any pixel work" promise is held.
   Taken:    4a, hold it in the signature. The pre-pixel check added in round 2 was a call whose
             value was discarded, so no input could distinguish it running from it not running:
             delete its body and `encode_packed` resolves the store again and refuses with the
             same message on the same paths. `encode_packed` now takes the ALREADY-RESOLVED store
             as a parameter - the shape `encode_pages_rgb8` and `encode_tiled` already used - so
             every entry point must resolve it, and take its refusal, before the pass that
             produces the packed bytes. The ordering is a compile-time obligation rather than a
             check that could be deleted unnoticed, and a reservation is no longer built twice.
             The path test now reads the refusal's MESSAGE under a tile size the layout stage also
             rejects, which is what separates "resolved before the layout stage" from "resolved
             inside it"; the round-2 `is_err` assertion could not, since both orders refuse.
   Rejected: 4b, a test that asserts only that each path errors - it cannot see the ordering, which
             is precisely why it left the claim unpinned. 4c, excluding the check from mutation -
             that would exclude the very behaviour the review asked to be made true. 4d, deleting
             the claim from the docs - it is true and now enforced, so narrowing it would understate
             what the code does.
   Residual: resolving the store before the pixel pass WITHIN a single entry point still changes no
             output, so that last step is held by `encode_packed`'s signature and stated as such in
             the test's comment rather than promised as something a test can falsify.
   Reverses: give `encode_packed` back its own `c2pa_store()` call and drop the parameter.

Unresolved review notes

None.


Round 4 — review repairs F1–F5, and a bound on the reservation

Branch feat/446-tiff-metadata-seam; four commits on top of 01668a81. Still an unattended run:
no human approved this round either, and the record below is what a human reads afterwards.

F1 (Medium) — a pointer on a page the reader discards no longer fails the read

read_metadata handed one flat POINTER_TAGS list to gamut_ifd::read_tree, which resolves it at
every node of every page, while the blocks and the Exif sub-IFD come from IFD 0 alone. The
regression class the list exists to prevent therefore survived, moved from a tag this reader ignores
to a page it ignores. Both falsifiers from the review were reproduced here before the fix and are
now pinned as tests (crates/gamut-tiff/tests/metadata.rs):

File (both decode to pixels happily) metadata() before after
two-page classic TIFF, page 0 XMP, page 1 ExifIFD = Long(0xFFFF_FF00) invalid input: TIFF: read out of bounds [origin: gamut-ifd, byte offset: 4294967040] Ok, XMP returned
two pages whose ExifIFD entries name one directory invalid input: TIFF: sub-IFD pointer loop [origin: gamut-ifd] Ok, page 0's Exif returned

The chain is now read with gamut_ifd::read, which follows no pointer at all, and the list is
resolved over IFD 0's subtree only, by hand through gamut_ifd::read_ifd_at — the per-pointer
control read_tree's own documentation points at. Within that subtree it stays one flat list at
every node
, exactly as read_tree applies one to a whole file, so IFD 0's behaviour is unchanged
and the flat-list dependence is now explicit in the code (resolve_pointers' signature takes the
list) rather than implicit in a call to another crate. The one over-reach that dependence causes —
InteroperabilityIFD followed at IFD 0, where a conformant file never puts it — survives and is
still documented as harmless on POINTER_TAGS, in STATUS.md §(b), and now on
TiffDecoder::metadata's # Errors (F3).

The C2PA entry stays reachable, shown rather than assumed. §A.3.6 puts it in the last IFD of the
main chain, and that entry carries the store's bytes rather than an offset, so it is reached without
following a pointer on a page whose pointers are never resolved. Two pieces of evidence: the new
two-page test reads its manifest store from the same page that carries the dangling ExifIFD
and asserts it comes back verbatim; and tests/c2pa.rs's existing multi-page and last-IFD tests
(a_multipage_document_puts_the_entry_in_its_last_page,
read_metadata_takes_the_store_from_the_last_ifd_of_the_chain) still pass unchanged. Reverting
read_metadata to read_tree in place makes only the two new tests fail, with the two messages
tabulated above — the store assertions pass either way, which is the same result stated as an
experiment.

F2 (Low/Medium) — the compile-time obligation, and the hole beside it

The obligation is to pass a Option<Cow<[u8]>>, not the one c2pa_store() returned. Replacing
store with None at encoder.rs:823 left the suite green, because tests/c2pa.rs covered the
strip, tile, palette, multi-page and BigTIFF paths and never encoded 16-bit, RGBA or bilevel. Two
repairs, both executed:

  • The ordering test grows the two entry points it was missing — RGBA and the palette path —
    so every entry point that resolves a store of its own is named there, each asserting the refusal
    is the C2PA one rather than the tiling one. encode_16bit and Bilevel were already in it.
    encode_pages_rgb8 stays out and the comment says why: it builds strip images directly, so there
    is no second refusal for the C2PA one to be told apart from.
  • tests/c2pa.rs gains the_pixel_paths_that_pack_their_own_buffer_place_the_store_too, covering
    the three paths nothing else there encoded — the three that do pixel work of their own first.
    Dropping store at each of the three call sites (encoder.rs:361, :744, :823) was applied
    by hand and each fails that test now; before it, all three left the suite at 196 passed.

F5 (Nit) — and a correction to a published commit message

tests/metadata.rs's dangling-ExifIFD test asserts the message (read out of bounds) rather than
is_err(). And the correction, made here because the commit is pushed and is not rewritten:
80f40853's message claims "both regressed cases are pinned", and that was not true — only the
dangling case was asserted; the shared-directory case was described but not pinned. Both are pinned
as of this round.

F4 — the mutation counts did not reproduce, so they are re-measured

The round-3 figure 142: 130 caught, 12 unviable does not reproduce and is withdrawn; the
verdict behind it (0 missed, gate green) does. This round measured 83 → 5 missed, repaired, then
79 — 76 caught, 3 unviable, 0 missed, with the command and base in the table below. The mutant
population of a --diff run is a function of the base ref's current tip, which moves while a stacked
branch is open, so a bare count is worth less than the command that produced it; both are given.

A bound on with_c2pa_reserved (same decision as #506)

with_c2pa_reserved returns Self, so it refuses nothing itself, and its length went straight into
vec![0; len] at the top of every encode. Past isize::MAX that panics with a capacity overflow
instead of returning — reproduced with usize::MAX, which raises raw_vec: capacity overflow.

The first attempt was a numeric bound (buffer's isize::MAX, and classic TIFF's 32-bit count and
offset words), and the mutation gate rejected it: > and >= differ only at exactly u32::MAX or
isize::MAX bytes, which is not a reservation any test can allocate, so the boundary's accepting
side was unassertable and the mutant survived. The bound is therefore taken by the operation instead
of by a comparison — Vec::try_reserve_exact, so a length no Vec<u8> can hold is
Error::InvalidInput at the same point, before any pixel work, with no comparison to get wrong and
no container arm to keep in step. The container bound went with it; it refused only lengths
gamut_ifd::write already cannot lay out. What stays outside this crate's reach is the allocator's:
a request the kernel overcommits succeeds here and aborts only when the bytes are written — the same
residual #506 records.

gamut-dng has the identical shape at crates/gamut-dng/src/encoder.rs:237 but belongs to #508, so
it is filed, not touched: #552. That issue's text suggests the numeric bound #506 took; the
fallible reservation above is the better mechanism and is noted here rather than by editing a filed
issue.

Validation (round 4)

Every command below completed in this round, from the worktree, in the memory-capped scope where the
contract requires one.

Command Outcome Class
cargo test -p gamut-tiff --all-features pass — 202 passed, 0 failed (196 before this round)
cargo clippy -p gamut-tiff --all-targets --all-features -- -D warnings pass (exit 0)
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt then … mise run fmt-check pass (exit 0). The prefix is the recorded nested-worktree workaround
mise run check-tests pass: "module docs, pinned proptest seeds and oracle filenames all conform"
convco check origin/feat/442-dng-c2pa-manifest-store..HEAD pass, "no errors in 16 commits"
mise run lint (systemd scope, MemoryMax=16G, MemorySwapMax=0, CARGO_BUILD_JOBS=2, ulimit -v 12000000) pass (exit 0). Run twice: before the mutation repair and again on the final head
mise run test (same scope) pass (exit 0) — 206 green test binaries, 0 failed. Run twice, same two heads
GAMUT_MUTANTS_BASE=origin/feat/442-dng-c2pa-manifest-store mise run mutants-diff --budget 16 first run on 11264899: 83 mutants — 74 caught, 4 unviable, 5 missed (exit 2). All five repaired in fix(tiff): kill the diff mutants the round-4 repairs left alive. Second run on 175b0ff5: 79 mutants — 76 caught, 3 unviable, 0 missed (exit 0) caused → repaired

The five survivors, and what each was: encoder.rs:234 > → >= in c2pa_store (the numeric bound's
unassertable boundary — see appended decision 13); metadata.rs:281 > → == and > → >=, and
:301 + → *, all in resolve_pointers (a depth bound of sixteen neither side of was reachable —
appended decision 14); metadata.rs:251 deleting pointer_offsets' Value::Long8 | Value::Ifd8 arm
(no BigTIFF reader test stood behind the 64-bit pointer form, so every BigTIFF's ExifIFD could have
been dropped silently). None was excluded; each is killed by a test.

Falsifier runs, each applied by hand to the working tree and reverted afterwards:

Experiment Result
read_metadata reverted to read_tree(data, POINTER_TAGS) the two new tests/metadata.rs cases fail with the two messages tabulated under F1; the other 8 in that file pass
storeNone at encoder.rs:361 / :744 / :823 the_pixel_paths_that_pack_their_own_buffer_place_the_store_too fails in each of the three
vec![0; len] restored in place of the fallible reservation a_reservation_no_buffer_could_hold_is_refused_instead_of_panicking panics in raw_vec (capacity overflow) instead of returning

No Cargo.toml was touched, so check-release-deps / check-ffi-features / check-ffi-header are
not triggered; no public C-surface type changed.

Issue (round 4)

Filed: #552 — "gamut-dng: bound DngEncoder::with_c2pa_reserved's length, as gamut-tiff and
gamut-avif do".

Decisions taken (round 4)

The decision record for this round was taken by the orchestrator on review of 01668a81 and is
reproduced verbatim:

1. F1: have metadata() ignore the pointers of pages it discards.
   Taken:    the per-subtree alternative (resolve EXIF_IFD at IFD 0, then the standard pointers
             within that subtree) is REJECTED because it needs a new read_tree variant taking a
             per-node list - a gamut-ifd surface change, which is unmerged base-PR territory under
             #508 and would widen this lane across a pull-request boundary. Make the flat-list
             dependence explicit in the code rather than leaving it implicit, correct the
             POINTER_TAGS docstring and STATUS.md, and pin both falsifiers.
             Verify explicitly that the C2PA entry stays reachable - it is a direct value rather
             than a pointer, so it should be unaffected, but show that, do not assume it.
2. F2, merged with the reviewer's design questions 3 and 4: keep the ordering test and give it the
   one thing that would make it non-redundant - extend it to encode_16bit, Rgba8 and Bilevel,
   asserting per path that the refusal is the C2PA one. Do not add a second test beside a redundant
   one.
3. F3: state the post-fix truth in the error documentation.
4. F5: assert the message rather than is_err(), and correct in the PR body the commit message's
   claim that both regressed cases are pinned. Do not amend the published commit.
5. F4: re-measure the mutation counts and publish what you actually measured, with a note that the
   earlier figure did not reproduce.
6. Add a bound on with_c2pa_reserved so a huge length is refused rather than aborting on allocation
   - the identical decision was already taken on PR #506 this round. gamut-dng has the same shape
   but belongs to #508, so FILE that consistency item rather than reaching into another pull
   request's crate.

Appended by the lane (forks the round-4 record did not settle), in the record's shape:

11. How "one flat list, IFD 0's subtree only" is implemented
   Taken:    `gamut_ifd::read` for the chain (it follows no pointer at all), then a local
             `resolve_pointers` that walks IFD 0's subtree with the same flat POINTER_TAGS list at
             every node, following each pointer with the PUBLIC `gamut_ifd::read_ifd_at` - which
             read_tree's own documentation names as the way to take per-pointer control - and
             carrying read_tree's own depth (16) and visited-offset guards, because they guard this
             walk. No gamut-ifd file is touched, so decision 1's boundary holds.
   Rejected: keeping read_tree and recovering from its failure (it would drop IFD 0's Exif whenever
             a later page's pointer broke - silent loss, the failure mode this seam has rejected
             throughout); resolving only EXIF_IFD at IFD 0 and only INTEROPERABILITY_IFD inside it
             (that is decision 1's named-and-rejected per-subtree shape, and it would also delete
             the documented over-reach rather than leave IFD 0's behaviour unchanged)
   Cost:     ~35 lines restating gamut-ifd's resolver, including its two guards and its rule for
             which value types are pointers. The alternative is a gamut-ifd surface change, which
             decision 1 froze out. If #508 merges and a per-node list is ever wanted, this function
             is the single place that would be deleted.
   Reverses: replace `resolve_pointers` with a per-node-list `read_tree` variant in gamut-ifd.
12. F2's residual hole, which decision 2 does not close on its own
   Taken:    decision 2 carried out as written (the ordering test now names RGBA and the palette
             path too), AND a second, non-redundant claim added in tests/c2pa.rs - that the store
             a path resolved actually reaches the file - on the three pixel paths nothing there
             encoded. The ordering assertion reads the message of a REFUSAL, so no extension of it
             can see a store that was resolved and then dropped: that is a successful encode. The
             instruction "do not add a second test beside a redundant one" is read as forbidding a
             new test that leaves the redundant one redundant; the ordering test is no longer
             redundant, and the new claim is in a different file about a different thing.
   Rejected: extending only the ordering test - the executed falsifier (store -> None at
             encoder.rs:823) would still not reproduce, so a Medium finding would stay open
   Reverses: delete `the_pixel_paths_that_pack_their_own_buffer_place_the_store_too`
13. How the reservation's upper bound is taken
   Taken:    `Vec::try_reserve_exact`, not a numeric comparison. A comparison's accepting side sits
             at exactly isize::MAX (or u32::MAX) bytes, which no test can allocate, so `>` vs `>=`
             was an unkillable diff mutant - measured, not predicted: it MISSED on the first
             mutants-diff run of this round. The fallible reservation refuses the same input at the
             same point with nothing to get wrong, and it is the operation that was panicking.
   Rejected: a numeric bound plus a mutation exclusion (the gate would stop seeing a real
             comparison); a round-number cap such as 64 MiB that no clause of the format or the
             container justifies - it would refuse lawful files to make a test cheap; keeping the
             container's 32-bit bound alongside (it refuses only lengths `gamut_ifd::write` already
             cannot lay out, and brings back the same unassertable boundary)
   Reverses: reinstate `max_store_len` and the comparison
14. The depth bound on `resolve_pointers`
   Taken:    TWO, not read_tree's sixteen. This walk follows two tags and the deepest tree they can
             legitimately reach is IFD 0 -> ExifIFD -> InteroperabilityIFD (EXIF 2.3 SS 4.6.3), so
             both sides of the bound become assertable: the Exif -> Interop round trip already in
             tests/metadata.rs, and an Interop directory inside an Interop directory, refused. At
             sixteen neither side could be reached, which is why `>` -> `>=`, `>` -> `==` and
             `depth + 1` -> `depth * 1` all survived the first run.
   Rejected: keeping sixteen for symmetry with read_tree - a generic reader needs sixteen because
             it is handed arbitrary tags; this one is not, and an unreachable guard is an untested
             guard
   Reverses: restore 16 and exclude the guard from mutation

Unresolved review notes (round 4)

  • The over-reach POINTER_TAGS documents is unchanged, by decision. InteroperabilityIFD is
    still followed at IFD 0, where a conformant file never puts it, and its resolved group feeds no
    field of TiffMetadata — so by the docstring's own rule ("a pointer whose target this reader
    throws away must not be able to fail the whole call") it is a residual instance of the same class,
    bounded to one out-of-spec tag on one directory. Removing it takes either a per-node pointer list
    in gamut-ifd or the per-subtree shape decision 1 rejected; both are outside this lane.
  • resolve_pointers restates gamut_ifd's resolver, including the depth and cycle guards and
    the rule for which value types are pointers, because the gamut-ifd surface is frozen for this
    lane (see appended decision 11). The two can now drift. The loop and depth refusals also carry
    origin: gamut-tiff rather than origin: gamut-ifd for IFD 0's subtree; the messages are
    unchanged.
  • encode_pages_rgb8 is not in the ordering test, because it builds strip images directly and
    offers no competing refusal to distinguish the C2PA one from. Its store placement is pinned in
    tests/c2pa.rs.
  • gamut-dng still panics on an oversized reservation (crates/gamut-dng/src/encoder.rs:237).
    Filed as gamut-dng: bound DngEncoder::with_c2pa_reserved's length, as gamut-tiff and gamut-avif do #552 rather than fixed, because that crate belongs to feat(dng)!: type the C2PA manifest store and report both exclusion ranges #508.

Round 5 — review repairs N1–N6

Branch feat/446-tiff-metadata-seam; five commits on top of 175b0ff5. Still an unattended run:
no human approved this round either, and the record below is what a human reads afterwards.

Every round-4 finding is repaired on this branch. Each was reproduced by execution before it was
touched, and the repair re-run against the same reproduction.

N1 (Medium) — the encoder wrote what its own reader refused

Reproduced, debug and release: a four-level Exif tree (ExifIFDInteroperabilityIFD
InteroperabilityIFD → leaf) encoded to a well-formed 380-byte TIFF, and
TiffDecoder::metadata on those same bytes returned
invalid input: TIFF: sub-IFD tree too deep [origin: gamut-tiff]. The crate emitted a file it
could not read — the round-1 finding's shape, reintroduced by lowering the reader's bound from 16
to 2 without lowering the writer's.

The bound now applies to both sides. TiffMetadata::check refuses an Exif directory nesting below
the pair EXIF 2.3 §4.6.3 allows, measured against MAX_POINTER_DEPTH itself so the two cannot
drift, and it is taken at one chokepoint (TiffEncoder::checked_store) together with the C2PA
store's refusals — before any pixel work on every entry point. After the repair the same
reproduction fails at the encode:
TIFF: an Exif sub-IFD may nest one further directory (ExifIFD -> InteroperabilityIFD, EXIF 2.3 §4.6.3) and this one nests deeper. The depth the pair does reach still round-trips
(a_decoded_exif_sub_ifd_re_encodes_into_a_fully_classified_file), so the agreement is proved from
both sides: what the writer accepts, the reader reads back; what the reader refuses, the writer
will not write.

Correction to appended decision 14 (round 4), which is not edited in place. That entry gives
mutation-assertability as the reason the reader's bound became two. That is not on its own a
sufficient reason to narrow a contract. The narrowing is retained, and it now rests on the clause:
ExifIFD and InteroperabilityIFD reach two levels and a third is out of spec (EXIF 2.3 §4.6.3).
Assertability is a property of a contract stated at the right width, never an argument for changing
its width.

N2 (Low) — the error documentation

crates/gamut-tiff/src/decoder.rs said "nesting deeper than 16 levels" a round after the shipped
bound became two. It now states the shipped bound and names the clause and the encoder-side twin.

N3 (Medium, robustness) — a wide pointer array made the walk quadratic

Reproduced in release on hand-built classic TIFFs whose single ExifIFD array names N distinct
all-zero directories (each parses as an empty IFD, so every call returns Ok):

file before after
0.15 MB 18.1 ms 1.4 ms
0.29 MB 65.3 ms 2.9 ms
0.59 MB 244.0 ms 5.2 ms
1.17 MB 978.2 ms 13.6 ms
2.34 MB 4229.0 ms 26.9 ms

Clean quadratic against near-linear; 157× at 2.34 MB. visited is now a BTreeSet, and insert
returning false is the loop guard, so the guard lost a line rather than gaining one. The same
defect in the shared crate this walk restates is filed as #578; the separate question — nothing
bounds the breadth of one pointer array — as #579.

N4 (Low) — the refusal that still happens

Restored, both the check and its documentation. Classic TIFF counts an UNDEFINED value with a
32-bit LONG, so a store past 4 GiB cannot be described whatever else holds it; without an early
check the reservation is allocated, zero-filled and the whole image compressed before
gamut_ifd::c2pa::append_store refuses it with a message about the file's offset limit rather than
about the length the caller passed. The bound is expressed as u32::try_from over the variant
rather than as a comparison, so it has no boundary whose accepting side needs a 4 GiB allocation to
assert — which is what made the deleted max_store_len unkillable. It is deliberately necessary,
not sufficient: whether the store's own offset fits depends on the size of the file it lands after,
and that refusal stays with append_store, where the file exists. The isize::MAX reservation test
now asks for BigTIFF, since classic TIFF refuses that length one check earlier for a different
reason.

Correction to appended decision 13 (round 4), which is not edited in place. Its "Rejected"
clause says the container's 32-bit bound "refuses only lengths gamut_ifd::write already cannot
lay out". That is true of the outcome and false of the cost: the later refusal arrives after
the reservation and the whole image have been materialised. The fallible reservation is kept; the
container bound is restored beside it.

N5 (Low) — one test, one reason

a_broken_pointer_on_a_page_the_metadata_discards_does_not_fail_the_read asserted four things
across three modules, and its store assertion killed nothing the inline
read_metadata_takes_the_store_from_the_last_ifd_of_the_chain does not already kill. It now
asserts the rule it is named for and nothing else. The fixture lost the store it no longer reads,
which also drops the file's reach into the C2PA locator and the strip decoder.

N6 (Nit) — the feature guard that cannot be restored

Reported as a dropped guard; it cannot be restored, and the reason is now in the source. bigtiff
is gamut-ifd's feature, enabled unconditionally by this crate's dependency on it and not
re-exported, so #[cfg(feature = "bigtiff")] in gamut-tiff names a feature this crate does not
have. Verified by adding the attribute and building: one
warning: unexpected cfg condition value: bigtiff, which the lint gate takes as an error under
-D warnings; and were it accepted the arm would vanish and every BigTIFF's ExifIFD would read
back as a plain integer.

Validation (round 5)

Commands verbatim, each run in this branch's worktree; the workspace-wide ones inside a
memory-capped scope (systemd-run --user --scope --slice=agents.slice -p MemoryMax=16G -p MemorySwapMax=0 -- env CARGO_BUILD_JOBS=2 CMAKE_BUILD_PARALLEL_LEVEL=2 sh -c 'ulimit -v 12000000; exec …').

command result
cargo test -p gamut-tiff --all-features pass — 24 binaries, 0 failed
cargo clippy -p gamut-tiff --all-targets --all-features -- -D warnings pass
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt then … mise run fmt-check pass
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
convco check origin/feat/442-dng-c2pa-manifest-store..HEAD pass — no errors in 21 commits
mise run check-commits pass — no errors in 30 commits
mise run lint pass (exit 0), whole workspace
mise run test pass (exit 0), whole workspace — 206 test binaries ok
GAMUT_MUTANTS_BASE=origin/feat/442-dng-c2pa-manifest-store mise run mutants-diff 95 tested, 91 caught, 4 unviable, 0 missed (exit 0); missed.txt and timeout.txt both empty

The __CARGO_TEST_ROOT prefix is a nested-worktree artefact of the fmt tasks, not a property of
this change: without it cargo walks past the worktree root when loading the tooling/* manifests.

mise run coverage was not run: this round adds no module, and the code it adds is reached by the
suite above. check-release-deps / check-ffi-features / check-ffi-header were not run: no
Cargo.toml and no public C-surface type changed.

The N1 and N3 reproductions were run from a scratch integration test that is not part of the
branch — it was written to falsify, executed before and after each repair, and deleted. What ships
is the narrow inline/integration tests named above.

Issues filed (round 5)

Nothing this round declines is left as an appended decision; each is a GitHub issue.

Decisions taken (round 5)

The decision record for this round was taken by the orchestrator on review of 175b0ff5 and is
reproduced verbatim:

1. N1: bound the writer to what the reader accepts, with a typed error naming the limit and the
   clause. Your posture throughout is strict in what you write, honest about what you read;
   refusing to *write* what you cannot *read* restores the agreement. Record explicitly that a
   mutation-assertability argument is not on its own sufficient reason to narrow a contract - the
   narrowing now rests on spec conformance, which is what it should have rested on.
2. N2: correct the error documentation to the shipped bound.
3. N3: fix the local copy here - a set instead of a linear scan is small and the measured cost is
   real - and file the same fix against the shared crate plus the separate question of bounding
   breadth.
4. N4: restore the documentation of the refusal that still happens, and restore the early check if
   it is cheap.
5. N5: narrow the test to one thing.
6. N6: restore the feature guard.
7. File three things that outlive this round: deleting the local resolver when the base merges; the
   asymmetry where a dangling pointer still costs a *single-page* file its store while a two-page
   file recovers; and the documented over-reach that remains. An appended decision does not survive
   as a task.

Appended by the lane (forks the round-5 record did not settle), in the record's shape:

15. Where the writer's bound is enforced, and how wide it is
   Taken:    `TiffMetadata::check`, called from a single new `TiffEncoder::checked_store`
             chokepoint alongside the C2PA store's refusals, so "refused before any pixel work" is
             kept in one place for all six entry points rather than at each entry point's own
             convenience. The bound is computed from MAX_POINTER_DEPTH itself, not from a second
             constant. It is CONSERVATIVE in one respect: the reader only refuses a tree too deep
             under a tag it follows, while `check` counts every sub-IFD group - a group under an
             unfollowed tag is a directory this crate could not return either (it reads back as the
             raw offset it was written to), so refusing it keeps the writer inside what the reader
             delivers rather than outside it.
   Rejected: making `TiffMetadata::apply` fallible (it is called after the layout stage on some
             paths, which is exactly the "after the image was compressed" failure this repair is
             about); a second depth constant on the writer (two constants drift, which is the
             defect being repaired); checking only the tags the reader follows (it would let the
             writer emit a group the reader silently degrades to an integer)
   Reverses: delete `TiffMetadata::check` and `TiffEncoder::checked_store`, restore the six
             `self.c2pa_store()?` call sites
16. N6 cannot be carried out as written, and is answered instead
   Taken:    the guard is NOT restored, because it cannot be: `bigtiff` is gamut-ifd's feature,
             enabled unconditionally by this crate's dependency and not re-exported, so
             `#[cfg(feature = "bigtiff")]` here names a feature gamut-tiff does not have. Verified
             by adding it and building: `unexpected cfg condition value: bigtiff`, which the lint
             gate takes as an error - and were it accepted the arm would vanish and every
             BigTIFF's ExifIFD would read back as a plain integer. The divergence from the source
             is documented at the site instead, with the reason and the verification.
   Rejected: adding a `bigtiff` feature to gamut-tiff that does nothing but re-export gamut-ifd's
             (a public feature invented to make a restatement look like its source, on a crate that
             has no features at all today); adding the attribute anyway (it fails the lint gate)
   Reverses: add a gamut-tiff `bigtiff` feature and forward it
17. N4's early check is expressed as a conversion, not a comparison
   Taken:    `u32::try_from(len)` under a match on the variant. The bound restored is the
             container's count word, and appended decision 13 removed its predecessor because
             `>` vs `>=` differ only at exactly u32::MAX bytes - a boundary whose accepting side no
             test can allocate. A conversion has no comparison to get wrong, so the check is back
             without the unkillable mutant that justified deleting it. The refusing side is
             asserted at u32::MAX + 1; the accepting side and BigTIFF's freedom from the bound are
             not, because both would mean successfully allocating 4 GiB in a unit test, and that is
             stated in the test.
   Rejected: leaving it to `append_store` (decision 4 asks for it if cheap, and it is: the cost
             deferred is a 4 GiB zero-fill plus a whole image's compression); reinstating
             `max_store_len` verbatim (it brings back the unassertable comparison)
   Reverses: delete the `countable` check and its test

Unresolved review notes (round 5)

Round 5 (continued) — a mutation timeout the first push introduced

Correction to the round-5 validation table above, which is left as written: its mutants-diff row
reports 95 tested, 91 caught, 4 unviable, 0 missed, measured locally on 91d79117. That figure
was reproducible on this machine and not on CI's. The incremental shard failed with exit 3 —
a timeout, not a survivor — on delete ! in TiffEncoder::c2pa_store, the classic-count guard
N4 restored. Inverting the guard lets the test's oversized reservation reach zeroed, whose
try_reserve_exact the CI runner's allocator granted; the 4 GiB zero-fill that followed ran past
the 60 s test timeout. Locally the same mutant was caught, because this machine zero-fills
4 GiB inside a minute. A mutant whose verdict depends on the hardware is not a gate.

Repaired in b6dd0d23 without weakening the check: the condition is spelled as the refusing one
(uncountable), so there is no ! for the mutation to delete, and the test asks for usize::MAX
rather than u32::MAX + 1, so a length that ever gets past a guard meets a reservation no
allocator can satisfy and fails instantly instead of allocating. Which of the two bounds answers
first is now what distinguishes the test from its BigTIFF twin, since both ask for the same length.

Re-measured on b6dd0d23, same command
(GAMUT_MUTANTS_BASE=origin/feat/442-dng-c2pa-manifest-store mise run mutants-diff):
94 tested, 90 caught, 4 unviable, 0 missed, 0 timeouts (exit 0), missed.txt and
timeout.txt both empty. One mutant fewer than before, which is the deleted !.

Gates re-run on b6dd0d23 after the repair: cargo test -p gamut-tiff --all-features pass;
cargo clippy -p gamut-tiff --all-targets --all-features -- -D warnings pass;
__CARGO_TEST_ROOT=… mise run fmt-check pass; mise run check-tests pass;
convco check origin/feat/442-dng-c2pa-manifest-store..HEAD pass (22 commits);
mise run lint and mise run test pass (exit 0, 206 test binaries ok).

Appended to the round-5 record, in the record's shape:

18. What to do about a mutant whose verdict depends on the machine
   Taken:    remove the mutation, not the check. `!countable` becomes `uncountable`, so the
             `delete !` operator has nothing to apply to, and the test's length becomes usize::MAX,
             so no allocator can satisfy it however the guard is mutated. The guard itself is
             unchanged in what it refuses.
   Rejected: a signature-anchored mutation exclusion for `c2pa_store` (it would blind the gate to
             every other mutant in a function that carries four real refusals); raising the test
             timeout (it makes the gate slower everywhere to accommodate one allocation nothing
             should be making); deleting the check that N4 asked to restore (the finding is real -
             without it the encode costs a 4 GiB zero-fill and a whole image's compression before
             refusing)
   Reverses: restore `!countable` and `u32::MAX + 1`

Round 6 — review repairs F1–F6

No human approved this plan. This is an unattended run; the decision record below is what a human
reads afterwards.

F1 (Medium) — the writer still emitted an Exif directory the reader could not return

Reproduced first, at the round-5 head b6dd0d23. A SubIFDs (330) or GPSInfo (34853) group
placed under ExifIFD encoded with no anomalies; TiffDecoder::metadata handed the pointer back
as Long([212]) — a raw absolute offset into the source file — and re-encoding that decoded value
produced a file this crate's own judge graded:

tag 330:   first anomalies []
tag 330:   raw field back = Some(Long([212]))
tag 330:   groups back = []
tag 330:   re-encode structural errors =
           [Structure { page: 0, detail: "TIFF: sub-IFD could not be parsed", severity: Error }]
tag 330:   fully classified = false
tag 34853: identical in every line.

Provenance, as the reviewer bisected it. Round 1 (20268b8) followed
STANDARD_POINTER_TAGS and the round trip was clean. Round 3 (65c48cb4) narrowed the list to
[EXIF_IFD, INTEROPERABILITY_IFD] to stop a dangling SubIFDs offset on a page hiding the
blocks — and in doing so re-opened round 1's own finding for the two tags it dropped, inside the
one directory the seam hands back. Rounds 4 and 5 did not notice and asserted the opposite in
three places.

Fix — decision 1, which restores the round-2 decision round 3 implemented only half of. The
tag list is now per level rather than one flat list, because the two levels answer opposite
questions:

level tags resolved why
IFD 0 ExifIFD only a followed pointer here can only add a failure mode; its target feeds no field. Round 3's finding stays closed.
inside the Exif subtree all four STANDARD_POINTER_TAGS an unfollowed pointer here becomes a stale offset in a directory the caller is handed.

The page scoping round 3 correctly introduced is untouched: the walk still runs over IFD 0's
subtree and no other page's. After the fix, the same reproduction prints raw field back = None, groups back = [330], re-encode structural errors = [], fully classified = true, for
all four tags.

The cost is stated rather than hidden, in the module docs, the README and STATUS: inside the Exif
subtree an unreadable target under any of the four now fails the whole read. That is the trade
ExifIFD itself already made — reporting exif: None for a directory the file declares is silent
loss — extended to the pointers that directory contains.

This closes #582 as a side effect. That issue asked for exactly this per-node list
(ExifIFD at IFD 0, the rest below it) and judged it a gamut-ifd surface. It is not: the list is
this crate's scoping decision and the local resolver already owned it. InteroperabilityIFD is no
longer followed at IFD 0. This run may not edit or close a filed issue, so it is recorded here.

F2 (Low) — five of six chokepoint sites could regress invisibly

Confirmed: reverting only encode_palette8 to the unchecked c2pa_store() and running the
whole crate suite failed zero tests. The round-4 per-path tests pin which store is resolved,
not that the configuration was checked — a path that drops the check still refuses a bad C2PA
store, so the C2PA message still comes back.

Decision 3: the invariant is now a test over entry points, not over the internal call sites —
every_public_encode_surface_refuses_an_exif_tree_its_own_decoder_could_not_read_back, table-driven
over all twelve public encode surfaces (the eight EncodeImage impls, encode_to_vec,
encode_with_report, encode_palette8, encode_pages_rgb8), asserting the refusal's message
rather than is_err, and that a refused encode appends nothing. It replaces the single-surface test
it subsumes.

What it catches that nothing else does: with encode_palette8 reverted, it is the only test in
the crate that fails (11 passed; 1 failed). Before it, that revert was invisible.

F3 (Low) — the count bound's magnitude was unpinned

Confirmed: changing u32::try_from to u16::try_from failed zero tests, because every fixture is
far below either bound; the mutation gate passed only because substituting true and false for the
whole condition is caught by other assertions, neither of which can see a bound that is merely
wrong. No test going through c2pa_store can reach the boundary either — a length large enough to
be refused there is large enough that zeroed's reservation answers first, which is exactly what
b6dd0d23 arranged so the mutant could not time out.

Decision 4: the bound is now the predicate uncountable_store_len(variant, len), unit-tested at
u32::MAX (accepted), u32::MAX + 1 (refused) and Variant::Big at usize::MAX (accepted) —
three assertions, no allocation, the pattern this repository already uses for arithmetic
unreachable in place. Verified: with the sixteen-bit narrowing applied, it is the only test that
fails.

F4 (Low) — silent loss

Confirmed: an Exif directory whose only content is a sub-IFD group has an empty fields() list,
so exif_ifd's filter dropped it. It encoded to a file with no Exif directory at all and read
back as exif: None, with no error. Reproduced at b6dd0d23 (f4: exif back = None); after the
fix the same case returns the directory with its group intact.

That shape is not hypothetical — it is precisely what read_metadata returns for an Exif directory
whose only entry is its InteroperabilityIFD pointer, so a decode/re-encode round trip lost the
directory silently. A group is one on-disk entry, so a directory holding one is not empty.

F5 (Nit) — the message named the wrong clause

check counted every sub-IFD group but reported every refusal with the ExifIFD -> InteroperabilityIFD depth clause, so a tree nested through a non-Interop tag was refused by a
message citing a rule it had not broken. The check is now two refusals with two messages, tag
checked first (a group under an unfollowed tag is unreturnable whatever its depth). The depth arm
still counts children, not groups, so a childless group is not newly refused.

F6 (Nit) — wrapping

The three markdown paragraphs this branch introduced at 110–161 columns are rewrapped to the ~100
the surrounding files use. Verified: no line added by this branch to README.md or
STATUS.md now exceeds 100 columns; the >100 lines that remain in STATUS.md (24, 25, 37, 44, 45,
52) predate this branch and are left alone.

Corrections to what this PR previously claimed

Decision 2. Three places asserted more than was true and are now corrected:

  1. README — "what the encoder writes the decoder reads back" now states the per-level rule
    explicitly, names all four resolved tags, names the cost of resolving them, and states that a
    vendor-private tag holding an offset is carried verbatim and is not round-trip safe.
  2. STATUS.md (b) and (d) — "the writer is bounded by what the reader accepts" now describes
    the two axes it is bounded on, tag and depth, each with its own message, on every public encode
    surface.
  3. src/metadata.rs — the "a round trip cannot emit a stale offset. This is the F1 fix" body
    text is replaced by the per-level rule and its stated cost, on TiffMetadata::exif,
    IFD0_POINTER_TAGS, EXIF_SUBTREE_POINTER_TAGS and pointer_tags.

Corrections to the five issues filed in round 5

Decision 8. This run may not edit or comment on a filed issue, so the corrections are recorded
here for whoever picks them up.

Validation (round 6)

command result
cargo test -p gamut-tiff --all-features pass — 79 lib + 12 tests/metadata.rs + the rest
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
convco check origin/master..HEAD pass — "no errors in 36 commits"
mise run lint pass — clean, workspace -D warnings
mise run test pass — 206 test result: ok, zero FAILED
mise run mutants-diff pass — 178 mutants tested in 10m: 166 caught, 12 unviable, 0 missed, 0 timeouts

fmt-check needs the __CARGO_TEST_ROOT prefix in a nested worktree (cargo otherwise walks past
the worktree root when loading the tooling/* manifests); the prefix changes no manifest. All
workspace-wide gates ran inside a 16 GiB memory-capped scope with CARGO_BUILD_JOBS=2.

Three targeted falsification runs, each reverting one thing and running the whole crate suite:

reverted fails
encode_palette8c2pa_store() 1 test: the new twelve-surface table (before this round: 0)
u32::try_fromu16::try_from 1 test: the new count-bound boundary test (before: 0)
nothing (baseline) 0

Risks and rollout (round 6)

  • Widened failure mode, deliberately. An unreadable SubIFDs/GPSInfo target inside an Exif
    directory now fails metadata() where it previously returned a stale offset. A real camera EXIF
    with a broken GPS pointer therefore now errors instead of silently handing back an unusable
    offset. This is the decision record's instruction and the same trade ExifIFD already made; it
    is documented in all three places. The alternative — degrading silently — is what gamut-dng
    does and what this crate deliberately does not.
  • F4 changes is_empty. A TiffMetadata whose Exif directory holds only a group now reports
    non-empty and is written. That is the point of the fix, but it is an observable change for a
    caller that constructed such a directory and relied on it being dropped.
  • No public API added or removed. uncountable_store_len, pointer_tags,
    check_exif_subtree, IFD0_POINTER_TAGS and EXIF_SUBTREE_POINTER_TAGS are all private.
  • Rollback is the five commits a31a47df..1aa39fb5, in reverse.

Decisions taken (round 6)

Map of the record — which earlier entries later rounds have corrected. The record is
append-only and nothing above is edited; this is the map a reader needs to read it correctly.

entry status now corrected by
Round 3's narrowing of POINTER_TAGS to [EXIF_IFD, INTEROPERABILITY_IFD] reversed inside the Exif subtree, kept at IFD 0 round 6, F1 (decision 1)
Round 2's "scope the reach to Exif at the first directory and the standard pointer tags within the Exif subtree" restored in full; round 3 implemented only the first half round 6, F1
Appended decision 15's "check counts every sub-IFD group" superseded: the tag rule is now its own refusal with its own message, and the depth arm counts children round 6, F5
Appended decision 17's u32::try_from under a match relocated, unchanged in behaviour, into a predicate that can be asserted at its boundary round 6, F3 (decision 4)
Round 5's three claims that "a round trip cannot emit a stale offset" (README, STATUS, module docs) corrected; they were true of two of the four standard pointer tags round 6, decision 2
Round 5's issue #582 ("InteroperabilityIFD is followed at IFD 0") resolved by the per-level list round 6, F1
Round 5's decision 6 / appended decision 16 (the bigtiff feature guard) unchanged; the refusal was verified in both halves and stands

The decision record for this round was taken by the orchestrator on review of b6dd0d23 and is
reproduced verbatim:

1. F1: resolve the standard pointer tags within the Exif subtree, keeping the page scoping round 3
   correctly introduced. This is not a new decision - it restores mine from round 2, which said to
   scope the reach to Exif at the first directory and the standard pointer tags within the Exif
   subtree. Round 3 implemented the first half and dropped the second, and that is exactly where
   the defect entered. Reproduce the reviewer's case first, then fix, then show the round trip
   clean.
2. Document precisely which tags are resolved, and that a vendor-private pointer is carried as a
   raw field and is not round-trip safe - extending the residue caveat the crate already states.
   Correct the three places that currently claim more than is true.
3. F2: make the invariant a test - table-driven over every public encode surface, not the internal
   call sites. This PR restructured the encoder around that invariant and it is currently pinned by
   review rather than by the suite.
4. F3: extract the bound into a small testable predicate and unit-test its magnitude at the
   boundary and one past it, with no allocation - the pattern this repository already uses for
   arithmetic unreachable in place.
5. F4: fix the silent drop.
6. F5, F6: correct the message and the wrapping.
7. Add a pointer at the head of your record section listing which entries have since been
   corrected. Keep the record append-only - do not edit any entry - but give a reader the map.
8. Record in the body, since this run cannot edit a filed issue: one of your five issues
   understates the work, because a third linear scan exists in the same crate that it does not
   mention; another carries a rhetorical overstatement about the file size to directory count
   ratio; and two of them collide unless taken in the order that avoids doing the substitution
   twice.

Appended by the lane (forks the round-6 record did not settle), in the record's shape:

18. How "the standard pointer tags within the Exif subtree" is expressed
   Taken:    a per-level list, `pointer_tags(depth)` - `[EXIF_IFD]` at depth 0,
             `gamut_ifd::tags::STANDARD_POINTER_TAGS` below it - threaded through
             `resolve_pointers` in place of the flat `tags` parameter. Depth 0 is the only level
             whose directory is a page and the walk never leaves IFD 0's subtree, so "not depth 0"
             IS "inside the Exif subtree" and no second predicate is needed. The list below is
             gamut-ifd's own constant, not a fourth local copy of the four tags.
   Rejected: adding SubIFDs/GPSInfo to one flat list (that is round 1, whose defect round 3 fixed:
             a dangling SubIFDs offset at IFD 0 hides every block); keeping the flat list and
             refusing such groups at the writer only (the reader would still hand a caller a stale
             offset read out of a file this crate did not write); resolving them but *degrading*
             an unreadable target to a raw field (a silent partial result, which is the shape this
             crate refuses on principle and gamut-dng adopts on purpose)
   Reverses: restore the flat `tags: &[u16]` parameter and the single `POINTER_TAGS` constant
19. The writer's bound splits into two refusals rather than staying one
   Taken:    `check_exif_subtree` refuses a group under a non-standard tag and a child nested past
             the depth with two distinct static messages, tag checked first. F5 is the visible
             symptom; the cause is that appended decision 15 deliberately made one check answer
             two questions, and once the reader resolves four tags rather than two, "every group"
             and "too deep" are no longer the same population. The depth arm counts CHILDREN, not
             groups, preserving `within_depth`'s exact semantics - a childless group reaches no
             further level and is not newly refused.
   Rejected: one message naming both rules (a caller cannot act on it); a dynamic message naming
             the offending tag (`Error::invalid_input` takes `&'static str`, and widening that is a
             gamut-core change outside this manifest); dropping the tag check now that four tags
             are followed (a group under tag 50000 still reads back as a raw offset)
   Reverses: restore `within_depth` and the single depth message
20. F4 is fixed in `exif_ifd`, not in `is_empty` or `apply`
   Taken:    the filter accepts a directory holding EITHER a field OR a sub-IFD group. `exif_ifd`
             is the one place that decides whether there is an Exif directory worth writing, and
             `is_empty` and `apply` both read it, so fixing it there keeps the three answers
             consistent by construction rather than by three edits agreeing.
   Rejected: fixing `is_empty` alone (then `is_empty()` says false and `apply` still writes
             nothing - a worse defect than the one being fixed); writing every `Some(exif)`
             including a genuinely empty directory (an `ExifIFD` pointer to a zero-entry directory
             is what the filter exists to prevent, and `an_exif_directory_with_no_fields_is_empty`
             pins it)
   Reverses: restore `.filter(|ifd| !ifd.fields().is_empty())`
21. The twelve-surface table replaces the single-surface test rather than joining it
   Taken:    `the_encoder_refuses_an_exif_tree_its_own_decoder_could_not_read_back` is deleted and
             `every_public_encode_surface_...` takes its place. The new test is a strict superset -
             same configuration, same asserted message, one of its twelve entries is the old
             test's one surface - so keeping both would be two tests failing for one reason, which
             docs/testing.md forbids. The configuration is the DEPTH refusal, not a C2PA one,
             because the C2PA half is what already passes when a site drops the check.
   Rejected: keeping both (redundant); putting the table inline in `src/encoder.rs` (it reads only
             public items, and `crates/gamut-tiff/tests/` is mutation-visible under
             `test_workspace = false`, so placement is free and the seam's other end-to-end claims
             already live in `tests/metadata.rs`); asserting `is_err` (cannot tell a surface
             honouring the bound from one refusing for an unrelated reason)
   Reverses: restore the single-surface test and delete the table

Unresolved review notes (round 6)

Round 6 (continued) — one self-review finding, and the gates re-run on the final head

Reviewing this round's own diff before it was final surfaced one defect in it, in
an_exif_directory_whose_only_content_is_a_group_is_still_written: the test cloned the Interop
directory it had just built and then asserted only sub_ifds().len() == 1, so the clone was
needless and the assertion could not distinguish the group it wrote from any other group of one.
Fixed in 81cab24f by comparing against the directory itself, which is both stronger and shorter.
No library code changed.

All gates re-run on the final head 81cab24f:

command result
cargo test -p gamut-tiff --all-features --lib pass — 79
cargo clippy -p gamut-tiff --all-targets --all-features -- -D warnings pass
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass
mise run check-tests pass
convco check origin/master..HEAD pass — "no errors in 37 commits"
mise run lint pass
mise run test pass — 206 test result: ok, zero FAILED
mise run mutants-diff pass — 178 mutants tested in 8m: 166 caught, 12 unviable, 0 missed, 0 timeouts

No Cargo.toml changed, so check-release-deps, check-ffi-features and check-ffi-header are
not applicable; no new module with thin test reach was added, so coverage was left to CI's
Coverage (test gate).


Round 7 — review repairs N1–N6

Issue #446, branch feat/446-tiff-metadata-seam, stacked on PR #508. No human approved this
round: it is an unattended run, and this record is what a human reads afterwards.

The lesson of the round, carried forward: round 6 closed with 178 mutants tested, 166 caught,
0 missed. That was not evidence this class had stopped. None of the four findings below is
reachable by any mutation operator — N1 is a missing branch, N2 and N3 are prose, and N4 is a
test that passes under the very mutation its comment names.

N1 (Medium) — a regression of the class round 6 was closing

check_exif_subtree inspected a directory's sub-IFD groups; resolve_pointers inspects its
fields. The asymmetry was the defect: the one shape the reader misreads was the one shape the
writer never looked at.

Reproduced first, at the round-6 head 81cab24f. For each of the four tags, an Exif directory
carrying the tag as a plain Value::Long:

tag 330   value 0xffff0000: ENCODES, DECODE FAILS: TIFF: read out of bounds [byte offset: 4294901760]
tag 330   value 0x8:        ENCODES, DECODE FAILS: TIFF: sub-IFD pointer loop
tag 34665 value 0xffff0000: ENCODES, DECODE FAILS: TIFF: read out of bounds
tag 34665 value 0x8:        ENCODES, DECODE FAILS: TIFF: sub-IFD pointer loop
tag 34853 value 0xffff0000: ENCODES, DECODE FAILS: TIFF: read out of bounds
tag 34853 value 0x8:        ENCODES, DECODE FAILS: TIFF: sub-IFD pointer loop
tag 40965 value 0xffff0000: ENCODES, DECODE FAILS: TIFF: read out of bounds
tag 40965 value 0x8:        ENCODES, DECODE FAILS: TIFF: sub-IFD pointer loop

That is exactly the file TiffMetadata::check documents it exists to prevent — one "read back as
something other than what was written".

The regression is confirmed, and it is narrower than the review stated. With pointer_tags
reverted to round 5's flat [EXIF_IFD, INTEROPERABILITY_IFD], tags 330 and 34853 do round-trip
(Some(Long([4294901760])), Some(Long([8]))) — so round 6 newly broke those two. But 34665 and
40965 fail identically at round 5: the blind spot itself predates round 6 for the two tags round
5 already followed. Round 6 widened an existing hole from two tags to four rather than opening it.

The fix inspects what the resolver inspects, at every level of the subtree, and is shaped by
the value's type rather than by its tag. pointer_offsets accepts only LONG/IFD/LONG8/
IFD8, so those four types under those four tags are refused, and every other type is left alone
— a SHORT under SubIFDs is a pointer to neither side.

After, all 20 combinations (4 tags × 4 pointer types + 4 tags × SHORT):

tag 330   LONG far / LONG near / IFD / LONG8 : ENCODE REFUSED: TIFF: an Exif sub-IFD may not carry
                                               a plain field under a standard pointer tag …
tag 330   SHORT (not a pointer)              : ENCODES, DECODES -> Some(Short([8]))
… identical for 34665, 34853, 40965.

No case remains in which this encoder emits a file its own decoder rejects. Literal round-tripping
of a pointer-typed field is impossible by the reader's own rule — a LONG under a pointer tag is
a pointer, there being no other signal in a TIFF — so the shape that round-trips is the refusal,
plus the non-pointer types, both shown above.

Filed as #601: gamut_ifd::Ifd::set_sub_ifd already documents that its tag "must not also
be set as a regular field", and nothing enforces it. gamut-ifd is frozen base territory for
this lane, so the repair was made in the consumer and the question raised there.

N2, N3 — two false closure claims, and how the set was derived instead of listed

This is the third false closure claim on this pull request, so the sites were derived
mechanically
, not listed. Four greps over crates/gamut-tiff/:

sweep query hits
A every /// / //! line naming a pointer tag by name or number 38
B every /// / //! line naming the follow/resolve contract (follow*, resolve*, raw offset, stale offset, absolute offset, dangling) 46
C the crate's markdown for the same terms 30
D error-message string literals naming the tag set 8

Deduplicated, that is eight sites that state this contract: TiffMetadata::exif,
TiffMetadata::check, IFD0_POINTER_TAGS, EXIF_SUBTREE_POINTER_TAGS, pointer_tags,
check_exif_subtree (all src/metadata.rs), TiffDecoder::metadata (src/decoder.rs),
TiffEncoder::with_metadata (src/encoder.rs), plus README.md and STATUS.md. Two had gone
false and two were incomplete; the sweep is what found README.md and STATUS.md, which the
review did not name.

  • N2, src/decoder.rs — claimed only ExifIFD (34665) and InteroperabilityIFD (40965) are
    followed. False in both directions: a dangling non-listed tag inside the Exif directory now
    fails the call, which the doc implied cannot happen, and a dangling InteroperabilityIFD at
    IFD 0 now succeeds, which the doc said is followed. It now states the per-level rule and what
    each level costs.
  • N3, src/encoder.rs — said nesting is "the one thing" that could break the agreement. There
    are three, each with its own message; all three are now named.
  • TiffMetadata::check — said "two bounds", "two distinct refusals". Now three, and it states
    why only the Exif subtree is checked (the blocks apply writes into IFD 0 sit under tags no
    level treats as a pointer).
  • README.md and STATUS.md — the writer's bound is restated in both; both now carry the
    field clause and the reason the writer must inspect fields rather than groups.

Pinning the enumeration

EXIF_SUBTREE_POINTER_TAGS is gamut_ifd::tags::STANDARD_POINTER_TAGS — a sibling crate's
constant — while eight sites and two error messages name its four members one by one. A fifth
member added upstream would widen a public contract with nobody deciding it.

Both refusal messages are now named constants (POINTER_FIELD_REFUSAL, FOREIGN_GROUP_REFUSAL),
and the_exif_subtree_pointer_tags_are_the_four_this_crate_documents asserts the constant equals
[330, 34665, 34853, 40965] and that each message names all four by name. Reading constants only,
it exercises no code path, so it is the sole failure under a fifth member and under nothing else.

N4 — the test that passed under the regression it named

a_standard_pointer_at_ifd_0_that_feeds_no_field_is_left_alone passed with pointer_tags
returning the full set at every level. Verified at 81cab24f: 1 passed; 0 failed. Its first
assertion called read, which resolves no pointer under any configuration; its second asked for
exif, which stays None whether a SubIFDs pointer at IFD 0 was resolved or not.

It now drives resolve_pointers directly at the depth read_metadata calls it with, because IFD 0
is the one directory the seam never hands back, and asserts the field stays the integer it was read
as and becomes no group. Under the regression it now fails, as its only failure.

N5 — the risks statement, corrected against this lane's favour

Round 6's risks section said: "A real camera EXIF with a broken GPS pointer therefore now errors
instead of silently handing back an unusable offset." That is wrong, and the correction shrinks
the stated risk.
GPSInfo (34853) belongs to the 0th IFD, not to the Exif directory — this
workspace's own reader says so, crates/gamut-exif/src/exif.rs:15: "GPSInfo pointer (0th IFD →
GPS sub-IFD)". At IFD 0 the widening does not apply: only ExifIFD is followed there, so a
camera's broken GPS pointer is tolerated exactly as before.

The widening therefore bites only a directory that nests SubIFDs or GPSInfo inside the
ExifIFD, which no conformant file does. The round-6 sentence is superseded by this one; the round
6 section is left unedited above, as the record requires.

N6 — the stale reference

tests/metadata.rs referred in the present tense to POINTER_TAGS, which round 6 split into
IFD0_POINTER_TAGS and EXIF_SUBTREE_POINTER_TAGS. It now names IFD0_POINTER_TAGS.

Each change is the sole failure under its own defect

Verified by reverting one thing at a time and running the whole gamut-tiff suite:

revert failures
delete the field-refusal loop (the N1 defect restored) the_writer_refuses_an_exif_pointer_tag_carried_as_a_pointer_typed_field — only
refuse by tag alone (drop the pointer-type shaping) a_value_no_reader_would_follow_survives_under_a_pointer_tag — only
pointer_tags returns the full set at every level a_standard_pointer_at_ifd_0_that_feeds_no_field_is_left_alone — only
a fifth member arrives in the upstream constant the_exif_subtree_pointer_tags_are_the_four_this_crate_documents — only
the same, with round 6's body of the IFD-0 test passes — which is N4

Validation (round 7)

All commands run in this worktree at head 31c08a63, in the mandated memory-capped scope for the
workspace-wide ones. uptime checked before each: load average 2.9–3.6, well under 32.

command result
cargo test -p gamut-tiff --all-features pass — 82 lib + all integration tests
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt then mise run fmt-check pass (the prefix is the known nested-worktree artefact)
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
convco check origin/master..HEAD pass — "no errors in 40 commits"
mise run lint pass — exit 0, workspace clean under -D warnings
mise run test pass — exit 0, 206 test binaries, 0 failures
mise run mutants-diff 178 mutants tested in 10m: 166 caught, 12 unviable, 0 missed, 0 timeouts (exit 0). The totals coincide with round 6's because the repair adds no new mutable expression: a for over a const slice guarding an early return Err yields no operator beyond the function-level one already in the set. Both mutants covering check_exif_subtreereplace check_exif_subtree -> Result<()> with Ok(()) and delete ! in check_exif_subtree — are caught. mutants.out/missed.txt and mutants.out/timeout.txt are both empty. This is exactly the round's lesson: the gate cannot see a missing branch, which is what N1 was.

No Cargo.toml was touched, so check-release-deps / check-ffi-features / check-ffi-header do
not apply. No new module was added, so coverage does not apply.

Risks and rollout (round 7)

  • A shape that used to encode is now refused. An Exif directory carrying a standard pointer tag
    as a pointer-typed field is rejected by with_metadata's encode. Nothing this crate's own
    decoder produces has that shape — since round 6 such a file fails metadata() outright — so the
    exposure is hand-built directories and directories parsed by third-party code. The alternative is
    emitting a file this crate cannot read back, which is the defect.
  • Deliberately not widened to a blanket tag ban. A non-pointer type under those tags still
    encodes and reads back verbatim, because the reader leaves it alone. Refusing it would reject a
    legal directory for a shape no reader misreads.
  • Correction to round 6, in this lane's disfavour to state and its favour in fact: see N5. The
    widening does not affect a camera file with a broken GPS pointer.
  • No public API added or removed. POINTER_FIELD_REFUSAL, FOREIGN_GROUP_REFUSAL,
    check_exif_subtree, pointer_tags, IFD0_POINTER_TAGS and EXIF_SUBTREE_POINTER_TAGS are all
    private. The only observable change is which inputs TiffEncoder refuses.

Issues filed (round 7)

Decisions taken (round 7)

1. Taken:    make `check_exif_subtree` inspect the *fields* `resolve_pointers` inspects, not only
             the groups a caller builds, and shape the refusal by the value's pointer type rather
             than by its tag.
   Rejected: refusing the four tags outright regardless of value type (rejected on evidence: a
             `SHORT` under `SubIFDs` encodes and reads back verbatim both before and after, so a
             blanket ban rejects a directory no reader misreads — shown by the sole-failure revert
             table above); narrowing the reader instead (impossible: a TIFF carries no signal
             distinguishing a pointer field from an integer field under the same tag).
   Reverses: nothing; it completes round 6's decision 1.

2. Taken:    derive the set of documentation sites by four greps over the crate for the contract's
             own terms, and report the queries and hit counts, rather than listing sites by hand.
             A hand-written list is what produced three false closure claims.
   Rejected: correcting only the two sites the review named (rejected on evidence: the sweep found
             `README.md` and `STATUS.md` restating the same contract, neither named by the review).
   Reverses: nothing.

3. Taken:    hoist both refusal messages to named constants and pin the tag enumeration in one
             assertion that reads only constants.
   Rejected: pinning by triggering the two refusals (rejected: the test then fails under the N1
             fix's revert as well, so it would not be the sole failure for its own defect);
             asserting the prose with `include_str!` (rejected: it pins wording, not membership,
             and fails on an unrelated edit).
   Reverses: nothing.

4. Taken:    rewrite the IFD-0 scoping test to drive `resolve_pointers` at the depth
             `read_metadata` calls it with, since IFD 0 is the one directory the seam never returns
             and no public observation can see whether its pointers were resolved.
   Rejected: keeping the public-API form and adding an assertion (rejected on evidence: `exif`
             stays `None` under the regression either way).
   Reverses: round 6's body of that test.

5. Taken:    append the N5 correction as a new section rather than editing round 6's risks entry,
             per the publishing contract that an entry is never edited in place.
   Rejected: silently correcting the sentence.
   Reverses: nothing.

6. Taken:    file the multi-directory `ExifIFD` array loss (#599) and the `gamut-ifd` setter
             invariant (#601) rather than fixing either here — the first is a contract decision on
             a 1.x public type, the second is in a crate this lane treats as frozen base.
   Rejected: narrowing `TiffMetadata::exif` behaviour here.
   Reverses: nothing.

7. Taken:    do NOT add an end-to-end integration test for the N1 refusal.
   Rejected: adding one (rejected on reach: the composition it would assert is already pinned —
             `every_public_encode_surface_refuses_an_exif_tree_its_own_decoder_could_not_read_back`
             pins that every surface calls the checked form, and the new inline test pins that the
             checked form refuses. A third test would duplicate reach without adding a failure
             mode, and this pull request has already been marked down for tests whose reach exceeds
             their claim).
   Reverses: nothing.

Unresolved review notes (round 7)

Round 8 — review repairs F1–F5, the enumeration gap, and a correctly-based mutation figure

Issue #446, branch feat/446-tiff-metadata-seam, stacked on PR #508 (base
feat/442-dng-c2pa-manifest-store). No human approved this round: it is an unattended run, and
this record is what a human reads afterwards.

The lesson of the round. Round 7 shaped the writer's check by the in-memory Value variant.
The reader classifies by the on-disk type code. Value::Unknown is the one value where those
two disagree, so the class round 7 was closing stayed open one shape wider. Every repair below is
stated at the write/read boundary, and every enumeration it depends on is now derived from a
constant or a function rather than written out by hand — a hand list short by one is what produced
seven rounds of this.

F1 — the discriminator is now the on-disk type code

gamut_ifd::UnknownValue::new validates only that the value word is the file variant's offset
width. It accepts a recognised type code, write emits that code verbatim, and the reader
classifies the entry by it. So a Value::Unknown built at code 4, 13, 16 or 18 is a plain field to
a variant-shaped predicate and a pointer to the reader. It is reachable through the public API
with no unsafe and no hand-built bytes.

Reproduced at the round-7 head's predicate, over this round's derived sweep (see below), by
reverting the one clause and counting instead of asserting:

cases exercised encode-then-fail-to-decode
variant-shaped predicate (round 7) 480 48 — all TIFF: read out of bounds
type-code predicate (this round) 432 0

The 48 are exactly the Unknown-at-a-pointer-code cases: 16 in classic TIFF (codes 4 and 13, the
only pointer codes whose type fits a 4-byte value word) and 32 in BigTIFF (codes 4, 13, 16, 18),
each across the four tags and both nesting levels. Post-fix they are refused by check, which is
why the exercised count falls by 48.

Reconciliation with the review's figure, which was untrusted evidence and is now checked: the
review reported 64 of 384. The 16-case difference is construction, not disagreement — its sweep
also built an Unknown at a code whose type is wider than the variant's value word (LONG8 in
classic TIFF), which is written out of line, so the word is a raw file offset and the entry is
unreadable under any tag. That is a malformed entry rather than a misclassified pointer, this
crate cannot refuse it without refusing every genuine vendor entry, and every_representable_value
documents the exclusion in place. The review's other two messages (value offset out of bounds,
sub-IFD pointer loop) and its BigTIFF read-back-as-a-group case are properties of the value word
chosen, not of the classification; this sweep's word (0x08 repeated) yields one message. The
classification defect is the same defect in both accounts.

The fix. POINTER_TYPE_CODES = [4, 13, 16, 18] and is_pointer_typed, asked of
Value::type_code(), replace the pointer_offsets-shaped clause in check_exif_subtree. The
codes are pinned to the resolver by
the_pointer_type_codes_are_exactly_the_codes_the_resolver_follows, so the two sides of the
boundary cannot drift apart silently.

The sweep is derived, not listed. every_type_code() filters 0..=u16::MAX through
gamut_ifd::FieldType::from_code; every_representable_value(variant) yields, per code, the
natural Value variant and the Unknown form carrying that same code, plus three Unknowns at
codes no field type claims. Crossed with the four pointer tags, classic and BigTIFF, and
top-level and one-level-nested, that is 544 enumerated / 432 exercised cases in
every_value_the_writer_accepts_under_a_pointer_tag_reads_back_as_a_field — measured, not counted
by hand. A type code added upstream enters the sweep with no edit here.

The sibling half is filed, not fixed (gamut-ifd is out of this lane's bounds and other lanes
are stacked on it): #608, gamut-ifd: UnknownValue::new accepts a recognised type code, so an Unknown value lies about what it is on disk. It extends #601's shape — a constructor that admits a
value nothing downstream can distinguish — and is cited from POINTER_TYPE_CODES and from
every_representable_value in the code. #601 was not edited.

F2 — a field and a group under one tag is refused, and the judge now grades it

Ifd keeps fields and groups in two lists, so one tag can sit in both. The writer then emits two
entries
under that tag, which TIFF 6.0 §2 does not allow, and the reader's duplicate-collapse
drops one. deconstruct — this crate's own judge — graded such a file clean and fully
accounted
, which is precisely why no round trip in seven rounds could see it.

  • The writer refuses it. FIELD_BESIDE_GROUP_REFUSAL, its own message, its own clause, pinned
    by the_writer_refuses_a_field_and_a_group_under_one_tag across all four tags with a SHORT
    value, so neither of the other two clauses can be what fires.
  • The judge grades it. New Anomaly::DuplicateTag { ifd, tag, entries } (the enum and the
    variant are both #[non_exhaustive], so this is additive). check_duplicate_tags re-reads the
    raw entry records of every directory the audit says it walked, because by the time the parsed
    tree exists the duplicate has already collapsed. Naming the directory by offset rather than by
    page is what lets it report a metadata sub-IFD several levels down.
  • Three doc sites that promised the oppositeTiffEncoder::with_metadata, README.md and
    STATUS.md — said unconditionally that what the caller supplies is what the file gets. They now
    state the refusal.

Refusing rather than allowing is decision 3: the alternative is an encoder that emits
non-conformant bytes and a reader that silently drops a field the caller set. A non-minimal writer
is the cheaper cost.

The oracle, on the one question a round trip cannot answer

Nothing in this pull request had consulted libtiff about the seam, and a repeated tag is exactly
the defect a self-consistent round trip is blind to: gamut writes and reads by one rule, so a wrong
rule is wrong symmetrically. tests/oracle_metadata.rs now builds a 2×2 RGB TIFF byte by byte
— no directory model can express two entries under one tag — carrying PhotometricInterpretation
twice, RGB then BlackIsZero. libtiff decodes it as RGB (it marks every occurrence after the
first to be ignored, tif_dirread.c, and warns the directory is unsorted); TiffDecoder::info
reports BlackIsZero, because gamut-ifd's model keeps the last. Which entry survives is a
property of the reader, not of the file — which is the whole justification for refusing to write
one.

F3 — the sole-failure table, re-derived by executing every row

Round 7's table was asserted. Executed at this head, one revert at a time, running the whole
gamut-tiff suite (--no-fail-fast), it is:

revert failures
classify by the Value variant again (F1 restored) every_value_the_writer_accepts_under_a_pointer_tag_reads_back_as_a_field — only
delete the field-beside-group refusal (F2 restored) the_writer_refuses_a_field_and_a_group_under_one_tag — only
widen IFD0_POINTER_TAGS by a fourth standard pointer tag ifd_0_resolves_the_exif_directory_and_no_other_standard_pointer — only
pointer_tags returns the full set at every level ifd_0_resolves_the_exif_directory_and_no_other_standard_pointer and a_broken_pointer_the_metadata_does_not_use_does_not_hide_the_blocks (2)
POINTER_TYPE_CODES short by one (IFD8 dropped) three: every_value_…_reads_back_as_a_field, the_pointer_type_codes_are_exactly_the_codes_the_resolver_follows, the_writer_refuses_an_exif_pointer_tag_carried_as_a_pointer_typed_field
a fifth member arrives in gamut_ifd::tags::STANDARD_POINTER_TAGS the_exif_subtree_pointer_tags_are_the_four_this_crate_documents — only
the duplicate-tag judge stops reporting (entries > 2) flags_a_directory_that_repeats_a_tag — only
the duplicate-tag judge reports every tag (entries > 0) four: flags_a_directory_that_repeats_a_tag plus the three whole-file accounting tests

Two rows fail more than one test and are published as such rather than trimmed. Both are
defensible: dropping a pointer type code is a wider defect than one test's claim, and a judge that
reports every tag breaks every file that has one. The pointer_tags row is the one that matters for
F3 — round 7's table claimed one failure there and there are two.

Correction to round 7's decision 4. Its recorded ground was "no public observation can see
whether IFD 0's pointers were resolved". That is false: the second failure in that row,
a_broken_pointer_the_metadata_does_not_use_does_not_hide_the_blocks in tests/metadata.rs, is
exactly such an observation, in this crate, today. The rewrite itself stands — driving
resolve_pointers at the depth read_metadata calls it with is still the narrower reach, and it is
the only test that fails for the IFD-0 scoping rule alone — but it is justified by reach, not by
impossibility. Round 7's entry is left unedited above, as the publishing contract requires.

F4 — the IFD-0 scoping rule is pinned, from the constant

Round 6 changed it and round 7 documented it, and nothing held it: a fifth tag added to
IFD0_POINTER_TAGS left 25 test binaries green, and a const's contents are not a mutable
expression, so the mutation gate cannot reach it either.
ifd_0_resolves_the_exif_directory_and_no_other_standard_pointer now derives its iteration set
from EXIF_SUBTREE_POINTER_TAGS
— it asserts that ExifIFD is resolved at IFD 0 and that every
other member of that constant is left as a field — so it fails under a widening without anyone
having repeated the list. Repeating a list by hand is the thing that let this through.

F5 — the line length is measured, not asserted

Round 6 claimed no added line exceeds 100 columns; round 7 added one of 151. Rewrapped, and the
claim is now a measurement made in this round:

$ git diff origin/feat/442-dng-c2pa-manifest-store...HEAD -- crates/gamut-tiff/README.md \
    crates/gamut-tiff/STATUS.md | grep '^+' | grep -v '^+++' | sed 's/^+//' \
  | awk '{ if (length($0) > m) m = length($0) } END { print m }'
100

The six lines over 100 columns still in STATUS.md are all present unchanged at origin/master;
this branch adds none. (.rs files are excluded from the claim: rustfmt does not rewrap comments,
and the workspace carries ~1200 such lines.)

The enumeration — 44 candidate sites, derived by script

The review derived 14 contract-stating sites against round 7's 10, and one of the three missed —
pointer_offsets — is where "a pointer's own type" was defined, and therefore the block F1 had to
change. So this round derived the set with a script rather than a list. Four sweeps over
crates/gamut-tiff/src, plus one repo-wide:

sweep query hits
1 doc lines (//////!) naming a pointer tag by name or number 40
2 lines naming the type enumeration (LONG8, IFD8, `LONG`, `IFD`, POINTER_TYPE_CODES, "type code") 48
3 lines making the round-trip promise ("reads back", "read back", "unchanged", "verbatim") 55
4 lines naming the pointer constants or predicates (STANDARD_POINTER_TAGS, pointer_offsets, is_pointer_typed, EXIF_SUBTREE_POINTER_TAGS, IFD0_POINTER_TAGS) 41
5 repo-wide, outside the crate, for the same contract 21 files

Each hit is then mapped to its enclosing declared item by walking forward to the first item
declaration, and deduplicated: 44 distinct sites across 8 files, of which 22 are reached by a
pointer sweep (1 or 4). That superset contains all 14 the review named, pointer_offsets
included, and it is reproducible — the script is what future rounds re-run instead of writing a
list. The five false closure claims are the ones fixed above: the N1 sentence and check's headline
(F1), the with_metadata/README/STATUS sentence (F2), the round-7 table row (F3) and round 6's
line-length claim (F5).

Decision 10 — the same asymmetry probed in gamut-dng, and filed

Nobody had checked whether the defect reaches the crate that shares the reader. It does. Probed
read-only; nothing in gamut-dng was changed, and no file outside crates/gamut-tiff/ is in
this diff.

  • DngEncoder applies no shape check to DngMetadata::exif. DngRewrite::open calls
    gamut_ifd::read_tree(data, STANDARD_POINTER_TAGS), which resolves the flat list at every
    node, so a pointer-typed field inside the Exif directory is followed as a file offset. All nine
    combinations tested (three tags × Long([8]), Long([26]), Unknown(code 4)) encode and then
    fail DngRewrite::open — the crate's own preserving open→edit→write path.
  • The field-beside-group shape loses both the field and the child directory, hands back a raw
    absolute offset as a plain integer, and gamut_dng::deconstruct grades the file clean and fully
    accounted.

Filed as #610, gamut-dng: DngEncoder writes an Exif directory DngRewrite cannot open, and deconstruct grades the loss clean, with the reproductions and the note that a check copied from
this crate's first attempt would miss the Unknown row.

Validation (round 8)

All commands run in this worktree at head dc49d235, the workspace-wide ones inside the mandated
memory-capped scope. uptime checked before each: load average 2.4–27.5, under 32.

command result
cargo test -p gamut-tiff --all-features pass — 86 lib tests + all integration tests
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass (the prefix is the known nested-worktree artefact; the task loops over every tooling/*/Cargo.toml, so it is needed regardless of this crate)
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
convco check origin/feat/442-dng-c2pa-manifest-store..HEAD pass — "no errors in 37 commits"
mise run lint pass — exit 0, workspace clean under -D warnings
mise run test pass — exit 0, 0 failures across the workspace
mise run mutants-diff with GAMUT_MUTANTS_BASE=origin/feat/442-dng-c2pa-manifest-store 109 mutants tested in 12m: 105 caught, 4 unviable, 0 missed, 0 timeouts (exit 0)
mise run mutants-diff with the default base origin/master 187 mutants tested in 15m: 174 caught, 13 unviable, 0 missed, 0 timeouts (exit 0). Per-crate split: 109 gamut-tiff, 46 gamut-ifd, 32 gamut-dng — so 78 of the 187 (42 %) belong to PR #508 underneath, and the stacked-base run selects exactly the 109 that do not

No Cargo.toml was touched (git diff --name-only against the base lists twelve files, all under
crates/gamut-tiff/), so check-release-deps / check-ffi-features / check-ffi-header do not
apply. No new module was added, so the local coverage gate does not apply; CI's Coverage (test gate) is green at this head.

Which base the mutation figure is about (decision 8). Round 7 published 178 / 166 caught / 12
unviable / 0 missed against origin/master; the review reproduced that byte for byte, so it is not
wrong — it is a statement about the wrong thing. Both bases are re-run at this head above, and the
split is exact: the origin/master selection is 187 mutants of which 78 (42 %) belong to PR #508
underneath
, while the stacked-base selection is precisely the 109 that are this branch's own. Both
are true; only the second is about this round, and the two agree on the only number that decides the
gate — 0 missed, 0 timeouts, both artefact files empty, so neither is an exit-3 timeout
masquerading as a pass.

What the mutation gate does not hold, stated plainly. F1 was a missing branch, F2 a missing
refusal and a missing judge clause, F4 the contents of a const. No mutation operator reaches any
of them, so a green mutants-diff is no evidence about this repair at all. What holds them is the
executed sole-failure table above — each is the failure of a test that fails for it, and the
POINTER_TYPE_CODES and EXIF_SUBTREE_POINTER_TAGS rows show the two constants are pinned against
drift in both directions.

Risks and rollout (round 8)

  • Two more shapes are now refused by with_metadata. A Value::Unknown carrying a pointer type
    code under one of the four standard pointer tags, and a tag carrying both a field and a group. The
    first was a file this encoder could not read back; the second was two entries under one tag, which
    is not a TIFF directory. Neither is producible by this crate's own decoder, so the exposure is
    hand-built directories and directories parsed by third-party code.
  • deconstruct reports on files it previously graded clean. Anomaly::DuplicateTag is new, so
    a caller that asserted anomalies.is_empty() on a file with a repeated tag now sees it. That is
    the point; the enum and the variant are #[non_exhaustive], so no match arm breaks.
  • The type-code rule is stricter than the variant rule in exactly one direction. A caller who
    deliberately stored an opaque Unknown at code 4 under SubIFDs — expecting it back verbatim —
    is now refused. There is no reader for which that expectation could have held.
  • No public API removed. One additive enum variant; every other new item
    (POINTER_TYPE_CODES, is_pointer_typed, FIELD_BESIDE_GROUP_REFUSAL, check_duplicate_tags)
    is private.

Issues filed (round 8)

Neither #601 nor any other existing issue was edited, commented on, labelled or closed.

Decisions taken (round 8)

Corrections to earlier entries in this record, collected here because a frozen entry is never
edited in place:
round 7's decision 4 — its stated ground is false; see F3 above for the true
one. Round 6's line-length claim — false at the round-7 head; see F5, now measured.

1. Taken:    classify a field under a standard pointer tag by its **on-disk type code**
             (`Value::type_code()` against `POINTER_TYPE_CODES`), not by its in-memory `Value`
             variant, so the writer's check and the reader's classification ask the same question
             at the boundary that kept breaking.
   Rejected: extending the variant match with a `Value::Unknown` arm (rejected on evidence: it is
             the same hand-maintained enumeration one shape wider — a type added upstream reopens
             it, which is the seventh-round failure mode itself); refusing all four tags outright
             (rejected: unchanged from round 7 — a `SHORT` under `SubIFDs` round-trips verbatim,
             and the sole-failure table's `a_value_no_reader_would_follow…` row holds it).
   Reverses: round 7's decision 1, in shape but not in intent — the clause it added is replaced by
             a type-code-shaped one, not removed.

2. Taken:    file the `gamut-ifd` half — a constructor that accepts a *known* type code into
             `Unknown` — as #608 rather than fix it, and cite it from the two sites in this crate
             that work around it.
   Rejected: fixing it in `gamut-ifd` (rejected on bounds: out of this lane's manifest, and other
             lanes are stacked on that crate); adding a comment to #601 (rejected: this run never
             writes to an existing issue).
   Reverses: nothing.

3. Taken:    refuse a tag carrying both a plain field and a sub-IFD group, and fix the judge —
             `deconstruct` grading such a file clean is a defect in this crate, and it is why no
             round trip could see F2. Correct the three doc sites that promised the opposite
             unconditionally.
   Rejected: allowing the pair and documenting the loss (rejected on cost: the alternative to a
             non-minimal writer is an encoder emitting non-conformant bytes and a reader silently
             dropping a field the caller set); reporting the duplicate from the parsed tree
             (rejected: impossible — `Ifd` has already collapsed it, which is why the raw entry
             records are re-read).
   Reverses: nothing.

4. Taken:    put the F2 claim to the crate's oracle. libtiff and this crate resolve a repeated tag
             to *different* entries, which is a fact no self-consistent round trip can produce, and
             `docs/testing.md` puts the oracle above the round trip for exactly this reason.
   Rejected: a round-trip test (rejected on technique: the defect is symmetric across this crate's
             own writer and reader); asserting libtiff's warning text (rejected: wording is not a
             contract — the decoded *pixel interpretation* is).
   Reverses: nothing.

5. Taken:    re-derive the sole-failure table by executing every row, publish the two rows that fail
             more than one test rather than trimming them, and append the correction to round 7's
             decision 4 with the true ground, leaving the frozen entry unedited.
   Rejected: asserting the table again (rejected: that is what made it wrong); editing round 7's
             entry (rejected: the publishing contract forbids it).
   Reverses: nothing; it corrects the *ground* recorded for round 7's decision 4, not the decision.

6. Taken:    pin the IFD-0 scoping rule with a test that derives its iteration set from
             `EXIF_SUBTREE_POINTER_TAGS`, so a widening fails it without anyone repeating a list.
   Rejected: listing the three other tags in the test (rejected on evidence: a hand-repeated list
             is what F4 and the enumeration gap both are).
   Reverses: nothing.

7. Taken:    rewrap the over-long line and make the length a measurement the round runs, scoped to
             the markdown this branch edits, with the `.rs` exclusion stated.
   Rejected: repeating round 6's claim (rejected: it was false); a repo-wide length gate (rejected
             on scope: ~1200 workspace lines exceed 100 columns and rustfmt accepts them — that is
             a repository decision, not this pull request's).
   Reverses: nothing.

8. Taken:    publish the mutation figure against the **stacked base**, and the `origin/master`
             figure beside it, each labelled with its base.
   Rejected: publishing only the master-based figure (rejected on evidence: 44 % of its mutants
             belong to PR #508); withdrawing round 7's figure (rejected: it was reproduced exactly
             — it is true about a different thing).
   Reverses: nothing; it re-frames round 7's figure rather than retracting it.

9. Taken:    leave round 6's pointer-resolution widening as it stands, and record that it bites only
             non-conformant nestings.
   Rejected: reopening it now (rejected: #582 and #581 hold the contract question, the review's own
             N5 weakens the case in both directions, and reopening a settled contract mid-loop with
             no new evidence is how a loop stops converging). #599's contract question is a 1.x
             public-type decision and stays with the issue.
   Reverses: nothing.

10. Taken:   probe `gamut-dng` read-only for the same asymmetry and file #610. It reproduces in both
             halves, including the `deconstruct`-grades-it-clean half.
    Rejected: fixing it here (rejected on bounds: `crates/gamut-dng` is outside this manifest);
              assuming it was clean because nobody had reported it (rejected: this run's recurring
              lesson is that the missed instance is one step from where anyone looked).
    Reverses: nothing.

11. Taken:   derive the contract-stating site set with a script — five recorded sweeps mapped to
             enclosing items — publish the queries and counts, and fix all five false closure
             claims.
    Rejected: listing sites by hand again (rejected on evidence: three successive hand lists were
              short, the last by four, and the one it missed was where the F1 fix had to go).
    Reverses: nothing.

12. Taken:   leave #608 as filed, carrying the review's 64-of-384 figure, and publish this round's
             own 48-of-480 measurement with the reconciliation here rather than editing the issue.
             The two agree; they differ by 16 out-of-line `Unknown` cases this crate deliberately
             does not refuse.
    Rejected: editing #608 (rejected: this run does not write to a filed issue, and the figure is
              not false — it counts a superset with a different construction); dropping the
              reconciliation (rejected: two different numbers for one defect in one record is what
              a reader has to be able to resolve).
    Reverses: nothing. (Appended by this lane; not in the launch record.)

Unresolved review notes (round 8)

Round 9 — the oracle test that could not fail for its own claim, and three contracts pinned

Issue #446, branch feat/446-tiff-metadata-seam, stacked on PR #508 (base
feat/442-dng-c2pa-manifest-store). No human approved this round: it is an unattended run, and
this record is what a human reads afterwards.

The lesson of the round. Round 8 put the repeated-tag claim to libtiff, and the test it wrote
asserted a decode the repeated tag could not move. An oracle test that passes without consulting
the oracle is the shape this pull request has now produced twice; the repair is to make the
repeated tag reach the readout, and then to falsify the test in both readers rather than one.

M1 — the fixture now repeats a tag the decoded bytes depend on

The old fixture repeated PhotometricInterpretation (262) and asserted libtiff's dimensions,
sample count and pixels. The oracle harness returns width, height, samples-per-pixel and 8-bit
pixels and no photometric field at all; it reads the tag only to invert 1-bit MinIsWhite data,
and this fixture is 8-bit. So libtiff's half of the assertion was blind to the repeat, and both
halves of the review's claim reproduce here by execution:

falsifier applied at the round-8 head old test
the two 262 entries swapped in the fixture passes — libtiff returns (2, 2, 3) and the same twelve pixel bytes, byte-identical; only this crate's answer moves (Rgb instead of BlackIsZero)
libtiff patched to keep the last duplicate (ma->tdir_ignore for na->tdir_ignore, both sites in tif_dirread.c, rebuilt) passes — the disagreement the test is named for is gone and nothing fails

The replacement repeats StripOffsets (273). Two 12-byte strips are laid down back to back and
the two entries point at different ones, so for uncompressed chunky data the scanline bytes a
reader hands back are the answer to "which entry did you keep". Both orderings are built —
leading entry at the first strip, then at the second — which separates "follows the first entry"
from "follows the lower offset". Proved in both directions at this head, one falsifier at a time,
each reverted afterwards:

falsifier new test
gamut_ifd::Ifd::set keeps the first occurrence (libtiff's rule) FAILED — "this crate must read the strip the LAST entry points at": got [1..12], wanted [101..112]
libtiff patched to keep the last duplicate, rebuilt from the submodule FAILED — "libtiff must read the strip the FIRST entry points at": got [101..112], wanted [1..12]
neither passes, both orderings

The submodule and gamut-ifd were restored and the suite re-run green after each; neither is in
this branch's diff (git status clean, git -C third_party/libtiff status clean).

M1, second half — a correction to round 8's decision 4

Round 8's decision 4 recorded, as its rejected alternative: "asserting libtiff's warning text
(rejected: wording is not a contract — the decoded pixel interpretation is)"
. That ground is
false as stated, and the round-8 entry is left unedited above as the publishing contract requires.

What the harness actually observes is: width, height, samples_per_pixel, and the 8-bit
pixels buffer. It does not surface libtiff's warnings, and it does not surface libtiff's
PhotometricInterpretation — the value is read internally and used only on the 1-bit path. So the
choice was never "warning text versus pixel interpretation": neither was observable, and the test
asserted a decode the repeat could not change. The decision to prefer an observable over a warning
string stands; its ground is that the pixels are observable and both other candidates were not,
which is exactly why the repaired fixture moves the pixels.

L1 — the sole-failure row is true for one of its three instantiations

Round 8's table row "widen IFD0_POINTER_TAGS by a fourth standard pointer tag →
ifd_0_resolves_the_exif_directory_and_no_other_standard_pointer — only"
does not hold for the
whole class. Re-executed at this head, one tag at a time, whole gamut-tiff suite with
--no-fail-fast:

widened by failures
SubIFDs (330) 2ifd_0_resolves_the_exif_directory_and_no_other_standard_pointer and a_broken_pointer_the_metadata_does_not_use_does_not_hide_the_blocks
GPSInfo (34853) 2 — the same pair
InteroperabilityIFD (40965) 1ifd_0_resolves_the_exif_directory_and_no_other_standard_pointer only

The row as published is true only of InteroperabilityIFD. The reason is visible in the second
test: it hangs a dangling pointer off SubIFDs and off GPSInfo and asserts the blocks survive,
so widening IFD 0 by either of those two makes the dangling pointer fatal; it never uses
InteroperabilityIFD. Two failures is the better outcome — a widening that costs the metadata
of a readable file should fail more than a naming test — so the correction is to the table, not to
the code. This is the same imprecision round 7's table was corrected for one row above; the fix
is to name the instantiation rather than the class.

I1 — the record named the wrong constant, and the code's choice is the stronger one

Round 8's F4 and its decision 6 say the pin "derives its iteration set from
EXIF_SUBTREE_POINTER_TAGS"
. The code iterates gamut_ifd::tags::STANDARD_POINTER_TAGS. Same
members — EXIF_SUBTREE_POINTER_TAGS is defined as that constant — and for a repair whose whole
point is deriving a set from the constant it names, naming the wrong one is not a detail. The
round-8 entries are left unedited; this is the correction.

Deriving from the sibling crate's constant is deliberate, and it is the stronger of the two. The
domain this sweep has to cover is "every standard pointer tag that exists", because the failure it
guards is a tag IFD 0 silently starts following. Derived from STANDARD_POINTER_TAGS, a fifth
member added upstream enters the sweep whatever this crate's alias is doing. Derived from the local
alias, a later narrowing of the alias would shrink the sweep silently — the hand-maintained-set
failure this pull request has now hit three times, moved one indirection further out. The comment
at the test now names the constant and says why, rather than the name being swapped in the record
to match.

The enumeration is now a command, not a count

Round 8 published "44 candidate sites across 8 files" from a script that was never committed and
queries stated in prose. That is half a derivation: nobody who was not here can re-run it, and the
review's re-sweeps disagreed on three of the five counts. The queries are therefore published
verbatim. Paste this at the repository root; it needs only sh, grep and awk:

item_of() {
  grep -rnE "$1" crates/gamut-tiff/src --include='*.rs' \
  | cut -d: -f1,2 \
  | while IFS=: read -r file line; do
      awk -v s="$line" 'NR >= s && /^[[:space:]]*(pub(\([^)]*\))?[[:space:]]+)?(unsafe[[:space:]]+)?(const|static|fn|struct|enum|trait|impl|type|mod|macro_rules!)[[:space:]!]/ { print FILENAME ":" NR; exit }' "$file"
    done | sort -u
}
S1='^[[:space:]]*//[/!].*(SubIFDs|ExifIFD|GPSInfo|InteroperabilityIFD|[^0-9](330|34665|34853|40965)([^0-9]|$))'
S2='(LONG8|IFD8|`LONG`|`IFD`|POINTER_TYPE_CODES|type code)'
S3='(reads back|read back|unchanged|verbatim)'
S4='(STANDARD_POINTER_TAGS|pointer_offsets|is_pointer_typed|EXIF_SUBTREE_POINTER_TAGS|IFD0_POINTER_TAGS)'
for n in 1 2 3 4; do
  eval "r=\$S$n"
  echo "sweep $n: hits=$(grep -rnE "$r" crates/gamut-tiff/src --include='*.rs' | wc -l) sites=$(item_of "$r" | wc -l)"
done
{ item_of "$S1"; item_of "$S2"; item_of "$S3"; item_of "$S4"; } | sort -u > /tmp/all_sites
echo "union sites: $(wc -l < /tmp/all_sites)  files: $(cut -d: -f1 /tmp/all_sites | sort -u | wc -l)"
{ item_of "$S1"; item_of "$S4"; } | sort -u | wc -l
grep -rlE '(SubIFDs|ExifIFD|GPSInfo|InteroperabilityIFD|STANDARD_POINTER_TAGS)' \
  --include='*.rs' --include='*.md' crates docs references tooling \
  | grep -v '^crates/gamut-tiff/' | sort -u | wc -l

At this head it prints, byte-identically on repeated runs:

sweep 1: hits=40 sites=18
sweep 2: hits=42 sites=27
sweep 3: hits=41 sites=30
sweep 4: hits=43 sites=22
union sites: 63  files: 9
33
26

These supersede round 8's figures, and are not a correction of them, because they are not a
measurement of the same thing: round 8's sweeps ran over a different head (this round adds ~90
lines of documentation to the same files), and its mapping step was described but not published,
so the two cannot be reconciled — which is the defect. Stated plainly: the mapping from a hit to a
"site" is a heuristic — the nearest item declaration at or after the hit — so 63 is a
reproducible proxy for "distinct contract-stating sites", not a semantic enclosure count. Its value
is that it is the same number for everyone and moves when the crate does. The script is not
committed because crates/gamut-tiff/** is this round's manifest and a sweep over the crate is not
crate content; publishing it verbatim is the alternative the record allows.

Design question 1 — the new anomaly variant carries a severity

Anomaly::Structure reports a Severity; Anomaly::DuplicateTag, added by this pull request, did
not, so a caller triaging a report had to know which variants carry one. It now carries
severity: Severity::Error — a repeated tag is a structural defect of exactly that kind, since
TIFF 6.0 §2 gives a directory one entry per tag and the field a caller set is dropped by at least
one reader. Added now because the variant is new and unreleased: the same field arriving later is a
breaking change to any #[non_exhaustive] match that binds it.
flags_a_directory_that_repeats_a_tag matches Severity::Error, and grading it Warning fails
that test alone (executed).

Design question 2 — the skip's promise is stated precisely and pinned

check_duplicate_tags walks the audit's IfdBody spans and continues past anything it cannot
re-read, on the recorded ground that such a directory "is already reported by the audit's own
finding"
. Nothing failed when that stopped being true. The doc now separates the two silent skips
and states the contract as what a caller may rely on:

  • a directory the audit did claim as an IfdBody span re-parses here by construction — the
    span exists only because the audit already read a directory at that offset, so that continue is
    unreachable rather than lenient;
  • a directory the audit could not parse is claimed as no span at all, so this pass never
    reaches it; it arrives as AuditFinding::SkippedSubIfd, which becomes an Anomaly::Structure of
    Severity::Error. No file is graded is_fully_accounted on the strength of a directory nobody
    read.

an_unparsable_sub_ifd_is_reported_rather_than_silently_skipped pins the second on the plain
unreadable target — a SubIFDs offset past the end of the file. That is SkipReason::Unparseable,
the arm neither the cycle test nor the depth test reaches, and no test reached it before: changing
its detail string fails this test alone (executed).

Design question 3 — the asymmetry is pinned

TiffMetadata::check refuses a sub-IFD group under a tag outside the four the reader resolves,
while the reader takes such a file without error and hands the tag back as the raw absolute
offset it was written as — the child directory is lost silently. That asymmetry is the stated
reason refusing is the conservative direction, and it was stated only in prose: nothing failed if
the reader began resolving the tag, and nothing failed if it began refusing it either.

the_reader_accepts_the_exif_group_tag_the_writer_refuses drives the reader over a file built
through gamut_ifd::write, so check is not in the path, and asserts both halves — the tag comes
back as a bare file offset, and no group carries it. Making the reader resolve the tag fails it
alone (executed). The refusal's own half is
the_writer_refuses_an_exif_group_under_a_tag_the_reader_does_not_resolve, unchanged.

Validation (round 9)

All commands run at head 8c09576e, in the lane worktree, the workspace-wide ones inside the
mandated memory-capped scope. uptime checked before each: load average 13.6–17.7, under 32.

command result
cargo test -p gamut-tiff --all-features pass — 88 lib tests + every integration test, 0 failures
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass (the prefix is the known nested-worktree artefact: the task loops over every tooling/*/Cargo.toml, so it is needed regardless of this crate)
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
convco check origin/feat/442-dng-c2pa-manifest-store..HEAD pass — "no errors in 42 commits"
mise run lint pass — exit 0, workspace clean under -D warnings
mise run test pass — exit 0, 206 test result: ok lines, 0 failures across the workspace
mise run mutants-diff with GAMUT_MUTANTS_BASE=origin/feat/442-dng-c2pa-manifest-store 109 mutants tested in 4m: 105 caught, 4 unviable, 0 missed, 0 timeouts (exit 0)

The base is stated beside the count deliberately: with the default origin/master base the
selection folds in PR #508's own mutants underneath and is not evidence about this branch.

No Cargo.toml was touched, so check-release-deps / check-ffi-features / check-ffi-header do
not apply. No new module was added, so the local coverage gate does not apply.

What the mutation gate does not hold, again stated plainly. This round's four repairs are: a
test fixture (no mutation operator applies to a test), a struct-literal field (severity: Severity::Error is not a mutable expression), a documentation paragraph, and two new tests. A
green mutants-diff is therefore no evidence about any of them. What holds them is that each was
falsified by executing the change it is supposed to catch — the two-way falsification table under
M1, and the three "fails alone" measurements under the design questions. The oracle fixture is the
one that could not be held by anything inside this repository at all: proving its libtiff half
required patching the vendored libtiff, rebuilding it and reverting.

Decisions taken (round 9)

Corrections to earlier entries in this record, collected here because a frozen entry is never
edited in place:
round 8's decision 4 — its rejected-alternative ground is false; the harness
observes neither libtiff's warning text nor its photometric interpretation. See M1's second half.
Round 8's F4 and decision 6 — they name EXIF_SUBTREE_POINTER_TAGS; the code derives from
gamut_ifd::tags::STANDARD_POINTER_TAGS. See I1. Round 8's sole-failure table, row 3 — true of
one of its three instantiations. See L1.

1. Taken:    replace the oracle fixture with one whose repeated tag reaches the readout — two
             `StripOffsets` entries at two different strips — and prove it fails in both
             directions: once with this crate's duplicate rule inverted, once with the vendored
             libtiff's inverted and rebuilt. Build both orderings, so "keeps the first entry" is
             separated from "keeps the lower offset".
   Rejected: keeping `PhotometricInterpretation` and asserting a photometric the harness would
             have to be widened to return (rejected on evidence: the widening is in a dev-only
             oracle crate outside this manifest, and the pixels are already an observable the
             repeat moves); repeating a tag whose value libtiff only warns about (rejected: the
             harness surfaces no warnings, which is the finding).
   Reverses: round 8's decision 4 in its fixture, not in its intent — the oracle is still what
             makes the disagreement observable; it now actually does.

2. Taken:    append the correction naming round 8's decision 4 and stating what the harness
             observes (width, height, samples-per-pixel, 8-bit pixels — and nothing else), leaving
             the frozen entry unedited.
   Rejected: editing the round-8 entry (rejected: the publishing contract forbids it, and a
             correction that rewrites its own subject is unverifiable).
   Reverses: nothing; it corrects the ground recorded for round 8's decision 4, not the decision.

3. Taken:    re-execute the widening row per instantiation and publish the tag and the count for
             each: `SubIFDs` 2, `GPSInfo` 2, `InteroperabilityIFD` 1. Explain why two failures is
             the better outcome rather than trimming to the one that reads cleanly.
   Rejected: restating the row as "1 or 2 failures" (rejected: a range is what let the imprecision
             through — the row has to name which tag it is about).
   Reverses: nothing; it corrects round 8's table row 3.

4. Taken:    correct the record to name `gamut_ifd::tags::STANDARD_POINTER_TAGS`, and state at the
             test why the sibling crate's constant is the stronger domain — a fifth member added
             upstream enters the sweep whatever this crate's alias does, while deriving from the
             alias would let a later narrowing shrink the sweep silently.
   Rejected: changing the code to iterate `EXIF_SUBTREE_POINTER_TAGS` so the record becomes true
             (rejected on evidence: that is the weaker domain, and rewriting code to match a
             record is backwards); swapping the name in the record with no explanation (rejected:
             the reason the wider constant was chosen is the part worth keeping).
   Reverses: nothing.

5. Taken:    publish the enumeration's queries verbatim as a runnable script and the counts it
             prints at this head, and state that the hit-to-site mapping is a heuristic and that
             these figures supersede rather than correct round 8's (different head, unpublished
             mapping — the two are not measurements of the same thing).
   Rejected: committing the script (rejected on bounds: this round's manifest is
             `crates/gamut-tiff/**`, and a sweep over the crate is not crate content; the record
             offers publishing verbatim as the alternative); republishing round 8's 44 (rejected:
             it is not reproducible, which is the finding).
   Reverses: nothing; it supersedes round 8's enumeration figures.

6. Taken:    give `Anomaly::DuplicateTag` the `severity` field its `Anomaly::Structure` sibling
             carries, graded `Severity::Error`, while the variant is still new and unreleased.
   Rejected: leaving it implicit and documenting that this variant is always an error (rejected on
             cost: adding the field after release is a breaking change to a binding match, and the
             consistency costs nothing today).
   Reverses: nothing; it completes round 8's decision 3.

7. Taken:    state `check_duplicate_tags`'s skip contract precisely — the two silent skips
             separated, one unreachable by construction and one reported by the audit — and pin the
             reported half on `SkipReason::Unparseable`, the arm no test reached.
   Rejected: reporting the duplicate-pass skip as its own anomaly (rejected on evidence: the
             audit's finding names the pointer tag and the offset, which is strictly more than
             this pass could say about a directory it never parsed); asserting the claim in prose
             again (rejected: that is what it already was).
   Reverses: nothing.

8. Taken:    pin the asymmetry between `check` and the reader with a test driving the reader over a
             file the writer refuses, built through `gamut_ifd::write` so `check` is out of the
             path, asserting the tag returns as a bare offset and as no group.
   Rejected: removing the asymmetry by making the reader refuse too (rejected on scope: that is a
             decoder contract change, not a repair of a stated finding, and it would refuse files
             third-party writers produce); leaving it as a comment (rejected: a stated asymmetry
             with no test that fails when it changes is a comment, not a contract).
   Reverses: nothing.

Unresolved review notes (round 9)

Round 9 (continued) — one self-review finding, and the gates re-run on the final head

Reviewing this round's own diff before finishing: the replacement fixture lays its two strips back
to back and wrote the second one's offset as + 12 — the first strip's length as a literal — while
StripByteCounts derived the same number from the array. The "value layout drifted" assertion
covers only the first strip, so widening the pixel blocks would have overlapped them silently.
Both now come from one binding (41178154). Nothing else was changed.

The head is therefore 41178154, not 8c09576e as the table above records, and every gate was
re-run there rather than the table being edited:

command result at 41178154
cargo test -p gamut-tiff --all-features pass — 0 failures
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass
mise run check-tests pass
convco check origin/feat/442-dng-c2pa-manifest-store..HEAD pass — "no errors in 43 commits"
mise run lint pass — exit 0
mise run test pass — exit 0, 206 test result: ok lines, 0 test result: FAILED
mise run mutants-diff with GAMUT_MUTANTS_BASE=origin/feat/442-dng-c2pa-manifest-store 109 mutants tested in 4m: 105 caught, 4 unviable, 0 missed, 0 timeouts (exit 0)

The enumeration counts published above were taken at 8c09576e; the only later change is inside a
test function body, so re-running the script at 41178154 prints the same seven lines (verified).

`gamut-tiff` had no metadata read or write surface at all: `tags.rs` named
XMP (700), IPTC/NAA (33723), ICC (34675) and the Exif/GPS/Interop pointers only
so `deconstruct` would not flag them unknown, and a caller wanting any of them
had to drop to the re-exported `gamut_ifd` read/write spine.

Add the seam: `TiffMetadata` is a `#[non_exhaustive]` struct of optional
payloads that `TiffEncoder::with_metadata` writes into IFD 0 — on the strip,
tile and multi-page paths alike — and `TiffDecoder::metadata` reads back. XMP,
IPTC-IIM and ICC are opaque bytes carried verbatim in both directions, the raw
blocks the workspace's metadata facade consumes; the `ExifIFD` is handed over
as `gamut_ifd::Ifd`, because it *is* a directory this crate has already parsed
and a byte blob would force every caller to re-parse it. Nothing is validated
or completed, so what the caller supplies is what the file gets.

The blocks are out-of-line values, so they displace the pixel data; libtiff
reads a gamut TIFF carrying all four back pixel-exact.
…ules

The manifest store joins the seam as a fifth carrier, but it is the one with a
placement rule of its own: C2PA 2.4 §A.3.6 puts its entry in the last IFD of the
main chain and its bytes at the end of the file, and §18.5.5 makes a signer
exclude two disjoint ranges — the store, and the `count` field of its entry —
from the `c2pa.hash.data` binding that §18.7.3.3 leaves as a TIFF asset's only
hard binding.

None of that is restated here. `gamut_ifd::c2pa` owns the tag, the placement,
the reserve-then-append relocation and the locator, and `gamut-dng` already
calls it; this crate wires the same helper to its three encode paths, so the two
formats cannot drift. `TiffEncoder::with_c2pa_reserved` writes a zero-filled
reservation for an external signer to overwrite in place, `encode_with_report`
returns the exclusion ranges, and `c2pa_exclusions` recovers them from any
TIFF's bytes — including one written through `encode_palette8` or
`encode_pages_rgb8`, which the object-safe `EncodeImage` seam cannot report
through.

The store is opaque and never byte-swapped: the header's `ByteOrder` does not
govern it (§A.3.6), which the tests pin with an asymmetric payload in a
big-endian file. Tag 52545 joins `is_known_tag`, so the strict deconstruct
claims the store as its entry's value span rather than flagging a private tag
and an unaccounted trailer, and libtiff still decodes such a file pixel-exact.
STATUS.md gains the semver-minor ledger entry for both halves — what the seam
carries, why every payload but the Exif sub-IFD stays opaque bytes, and which
clauses of C2PA 2.4 are `gamut_ifd::c2pa`'s rather than this crate's — plus the
new public items under the v1 surface's freeze list and a deferred row for the
typed `gamut-metadata` wiring this crate deliberately does not do. README.md
gains the matching Status bullet.
The tuple pattern `if let (Some((last, _)), true) = (images.last_mut(), store.is_some())` said the right thing awkwardly; a let-chain puts the condition first and the binding second, in reading order. No behaviour change.
`TiffDecoder::metadata` follows the `ExifIFD` pointer into a second directory
and `c2pa_exclusions` walks the IFD chain to its end to read an offset/count
pair — offset-driven reads of untrusted bytes on paths `decode_page` never
takes, so the existing byte-flip corpus could not reach them. `byte_flip_fuzz`
now takes the entry point under test as a closure, and both new entry points get
the truncation sweep and the 5000-mutation corpus.

Separately, BigTIFF is the one place the container variant changes what §18.5.5
names: the entry's count field widens from 4 bytes to 8. Pinned, together with
the store still landing verbatim at the end of the file.
Three defects found in review, each one the seam contradicting something it
already claimed.

A decoded Exif sub-IFD carried a dangling pointer. `read_metadata` asked
`read_tree` for `ExifIFD` alone, and `read_tree` re-parses only the tags it is
given, so an `InteroperabilityIFD` (40965) *inside* the Exif directory —
near-universal in camera EXIF — came back as a raw `Long` holding the source
file's absolute offset, which `apply` then wrote verbatim into a file laid out at
different offsets. Decoding and re-encoding produced a file the crate's own judge
rejects: not fully classified, with a `severity: Error` "sub-IFD could not be
parsed", breaking gamut-tiff's v1 zero-tolerance byte accounting on a file
gamut-tiff itself wrote. Read with `gamut_ifd::tags::STANDARD_POINTER_TAGS`
instead — the list the repository already provides and `gamut-dng`'s rewrite path
already uses — so a nested pointer directory becomes a child the writer re-points.

`metadata()` and `c2pa_exclusions()` disagreed about what a manifest store is,
while the docstring claimed they agreed. The reader accepted any `UNDEFINED`
value under 52545; `locate` additionally rejects one below `MIN_STORE_LEN` or a
duplicated entry. A four-byte entry therefore decoded to `Some(...)` that the
encoder then refused — exactly the decode → encode trap `locate` reports absence
to avoid. The reader now gates on the locator, as `gamut-dng` does.

`with_c2pa_reserved`'s documented minimum was wrong for BigTIFF. `append_store`
refuses a store that fits inline, so the true minimum is the variant's inline
threshold plus one: 8 in classic TIFF but 9 in BigTIFF. A BigTIFF reservation of
8 compressed the whole image before failing from `gamut-ifd`, despite the
`# Errors` promise to catch length "before any pixel work". `c2pa_store` now
gates on `min_store_len()` and both doc claims are corrected.

Also states three contracts that were decided but unwritten: the Exif directory's
entries are carried unchanged while its *ordering* is normalised (ascending tag,
duplicates collapsed, a child's next-IFD pointer ignored) rather than being
byte-identical; the blocks live in IFD 0 only, so a reader decoding one page of a
multi-page document must look there for them; and `metadata()` can fail on a file
`decode_image` decodes happily, because reporting "no EXIF" for a directory the
file declares is silent loss a caller cannot detect.
STATUS, README and the encoder docs all say a palette file carries a store and
reports it through `c2pa_exclusions`. Nothing called `encode_palette8` with
`with_metadata`, so that claim held by luck: it works today, and nothing would
have failed if it stopped. `encode_palette8` is an inherent method rather than an
`EncodeImage` impl — it needs a separate colour table — so `encode_with_report`
cannot reach it, which is precisely why the locator is the documented route and
why it needed its own test.
STATUS.md and README.md said the metadata was carried "verbatim in both
directions". That is exact for the byte payloads and approximate for the Exif
sub-IFD, which is a directory model rather than a byte range: `gamut_ifd::Ifd`
sorts fields by ascending tag, collapses a duplicated tag to its last occurrence,
and ignores a child directory's next-IFD pointer. Say "entries carried unchanged,
ordering normalised" and name the three, so nobody uses a re-encode to argue a
file is unmodified.

Also record what the reader now resolves and where the blocks live: every
standard pointer tag rather than `ExifIFD` alone, and IFD 0 only — with the cost
of that choice stated, since a reader decoding page 3 of a multi-page document
alone sees none of the blocks.
… read

Resolving every standard pointer tag fixed a dangling `InteroperabilityIFD` but
over-reached: `read_tree` takes one flat list and applies it at every node, so
`SubIFDs` (330) and `GPSInfo` (34853) were followed at IFD 0 too. Neither feeds
any field of `TiffMetadata` and neither is re-encoded by `apply`, which writes
into a directory the encoder builds fresh — so following them could only add
failure modes, and did. On a well-formed single-strip RGB file carrying XMP, a
dangling `SubIFDs` offset (and likewise a dangling `GPSInfo`) made `metadata()`
return `Err(TIFF: read out of bounds)` while `decode_image` succeeded, putting
XMP, ICC, IPTC and C2PA all out of reach because of a thumbnail pointer nobody
asked for. From the same code, two pages of a multi-page document whose
`SubIFDs` share one thumbnail directory failed with "sub-IFD pointer loop",
because `visited` spans the whole chain.

Scope the list to what the struct actually returns: `ExifIFD`, because that
directory is handed to the caller and may be written back, and
`InteroperabilityIFD`, the one standard pointer that occurs inside it. A broken
Exif pointer is still an error — its content *is* returned, so silence there
would be data loss — and both regressed cases are pinned.
`with_c2pa_reserved` promises the refusal is raised "before any pixel work", and
`c2pa_store()?` sits in `encode_packed`, the chokepoint every layout funnels
through. Two paths reach it late: `encode_16bit` first allocates and fills a
byte-order-corrected copy of the samples, and `EncodeImage<Bilevel>` first runs a
whole bit-packing pass. Both are O(width x height), so the documented claim was
false wherever it mattered most.

Add `check_c2pa` at the head of those two paths. The claim is now true on every
path, and a test asserts it per path shape — a mutant deleting either call leaves
every output byte-identical, so nothing else would have caught it.
Two overclaims, both found by reading the docs against the code.

The C2PA reader's rationale said agreeing with `locate` means a store read can be
written back. It does not, and a reader cannot promise that: it does not know
which container the caller will write. `locate` accepts a store of exactly
`MIN_STORE_LEN`, while writing BigTIFF needs 9 bytes, since 8 would pack into the
entry's value word instead of being placed out of line. Name the case, and say
the input is degenerate — 8 bytes is a JUMBF header with no content — so the
break's real scope is visible.

`TiffMetadata::exif` said pointer tags inside the directory come back parsed
"never as raw offsets". Only the standard pointer tags are recognised as
pointers, so a vendor private tag holding a `LONG` offset is carried through and
re-encoded verbatim — the same defect as a stale Interop pointer, for a narrower
input class, and one `deconstruct` cannot grade because it does not know the tag
is a pointer either. STATUS.md was already precise; make the API doc a caller
actually reads equally precise, and say plainly that a round trip through this
field does not prove a rewrite pointer-safe.
The pre-pixel C2PA check was a call whose value was thrown away, so
removing its body changed no output on any input: encode_packed resolved
the store again and refused with the same message. Nothing could tell
whether it ran.

encode_packed now takes the already-resolved store as a parameter, the
shape encode_pages_rgb8 and encode_tiled already use. Every entry point
must therefore resolve it — and take its refusal — before the pass that
produces the packed bytes, so the ordering is held by the signature
rather than by a check that could be deleted unnoticed, and a
reservation is no longer built twice.

The path test now reads the refusal's message under a tile size the
layout stage also rejects, which is what separates 'resolved before the
layout stage' from 'resolved inside it'; asserting is_err cannot.
`read_metadata` handed one flat `POINTER_TAGS` list to
`gamut_ifd::read_tree`, which resolves it at every node of every page,
while the blocks and the Exif sub-IFD come from IFD 0 alone. So a
pointer on a page the reader throws away could still fail the whole
call — the very thing `POINTER_TAGS` exists to prevent, moved from a
tag this reader ignores to a page it ignores.

Two files showed it, both of which `decode_image` reads happily. A
two-page classic TIFF whose page 1 carried `ExifIFD = Long(0xFFFF_FF00)`
answered `metadata()` with "TIFF: read out of bounds"; two pages whose
`ExifIFD` entries named one directory answered "TIFF: sub-IFD pointer
loop", because `read_tree` walks a whole file with a single `visited`
set and a second page naming a directory the first named looks exactly
like a cycle. Both are now read, and both are pinned.

The chain is read with `gamut_ifd::read`, which follows no pointer at
all, and the list is then resolved over IFD 0's subtree by hand through
`read_ifd_at` — the per-pointer control `read_tree`'s own documentation
points at. It stays one flat list applied at every node of that
subtree, so IFD 0's behaviour is unchanged, including the one
over-reach `POINTER_TAGS` already documented: `InteroperabilityIFD` is
followed at IFD 0 too, where a conformant file never puts it.
Narrowing that further would take a per-node list, which is a
`gamut-ifd` surface rather than a scoping decision this crate makes.

The C2PA manifest store is unaffected and is asserted so: §A.3.6 puts
its entry in the last IFD of the main chain, and that entry carries the
store's bytes rather than an offset, so the store is reached on a page
whose pointers are never resolved. The new two-page test reads its
store from the same page that carries the dangling pointer.

Also asserts the message rather than `is_err` where a dangling
`ExifIFD` must still be an error, so a refusal that named some other
pointer could not pass for it.
`encode_packed` takes the resolved C2PA store as a parameter, so no
entry point can forget to resolve one — but that is an obligation to
pass *a* store, not the one `c2pa_store()` returned. Replacing the
argument with `None` at the bilevel call site left the suite at 196
passed: `tests/c2pa.rs` covered the strip, tile, palette, multi-page
and BigTIFF paths and never encoded 16-bit, RGBA or bilevel, and a
dropped store is silent — the file is well formed and only
`c2pa_exclusions` disagrees.

Those three are pinned now, and they are the three that do pixel work
of their own before the call: a byte-order-corrected copy, an extra
sample, a whole bit-packing pass. Dropping the store at any of the
three call sites now fails.

The ordering test grows the two entry points it was missing — RGBA and
the palette path — so every entry point that resolves a store of its
own is named there, each asserting the refusal is the C2PA one rather
than the tiling one. Only `encode_pages_rgb8` stays out, and not by
choice: it builds strip images directly, so there is no second refusal
for the C2PA one to be told apart from.
`with_c2pa_reserved` returns `Self`, so it cannot refuse anything
itself, and the length it stores went straight into `vec![0; len]` at
the top of every encode. Past `isize::MAX` that panics with a capacity
overflow rather than returning, and the length is a caller's number:
a panic out of a library path is this crate's defect. `usize::MAX` was
the shortest way to reach it.

The bound is now checked with the existing minimum, before the
reservation is materialised, so an unusable length costs neither the
allocation nor the panic. It is the smaller of what a buffer holds and
what the container describes: classic TIFF counts an `UNDEFINED` value
with a 32-bit `LONG` and addresses it with a 32-bit offset, so nothing
beyond `u32::MAX` could be pointed at, while BigTIFF's words are
64-bit and leave only the buffer. A supplied store is held to the same
bound as a reservation, as it already was to the minimum. What stays
outside this crate's reach is the allocator's: a reservation the
machine has no memory for aborts, as any oversized allocation does.

The same decision was taken for `AvifEncoder::with_c2pa_reserved`, so
the two container crates of the C2PA epic now agree about it.
Five survivors, four of them the new code's own and each a real gap.

The reservation's upper bound was a numeric comparison whose accepting
side no test could assert — `>` and `>=` differ only at exactly
`u32::MAX` or `isize::MAX` bytes, which is not a reservation a test can
allocate. The bound is dropped for the thing it was standing in for:
the reservation is now taken with `try_reserve_exact`, so a length no
`Vec<u8>` can hold is a typed error at the same point, with no
comparison to get wrong and no container arm to keep in step. It also
retires the second bound, on the container's 32-bit count and offset
words, which refused only lengths the writer already cannot lay out.

`resolve_pointers` kept `read_tree`'s depth bound of sixteen, which
neither side of could be asserted: a generic reader needs sixteen
because it is handed arbitrary tags, and this walk follows two. The
deepest tree `ExifIFD` and `InteroperabilityIFD` can legitimately
reach is two levels (EXIF 2.3 §4.6.3), so the bound is two and both
sides of it are pinned — the Exif → Interop round trip already in
tests/metadata.rs, and an Interop directory inside an Interop
directory, refused here. That also kills `depth + 1` becoming
`depth * 1`, which had no observable effect while the bound was
unreachable.

`pointer_offsets`' 64-bit arm had no BigTIFF reader test behind it: a
resolver that knew only `LONG` would leave every BigTIFF's `ExifIFD`
in place as an integer and report `exif: None`. Pinned on the type as
well as on the result, so the fixture cannot stop exercising the arm
without saying so.
`with_metadata` took any nesting a caller built while `metadata()`
refuses a directory more than two levels under IFD 0, so this crate
wrote a well-formed four-level Exif tree and then rejected its own file
as too deep — the same shape as the round-1 finding on this branch, and
a direct contradiction of the seam's contract that what the file holds
is what the caller gets.

The bound now applies to both sides. `TiffMetadata::check` refuses an
Exif sub-IFD nesting below the `ExifIFD` -> `InteroperabilityIFD` pair
EXIF 2.3 §4.6.3 allows, as a typed error taken at one chokepoint with
the C2PA store's refusals, before any pixel work on every entry point.
It is measured against `MAX_POINTER_DEPTH` itself rather than against a
second constant, so the two cannot drift.

The reader's bound was narrowed from sixteen to two in the previous
commit for a reason that is not sufficient on its own: that neither side
of sixteen could be asserted, so a mutant lived. What justifies two is
the spec — `ExifIFD` and `InteroperabilityIFD` reach two levels and a
third is out of spec — and that is what the narrowing rests on now. A
mutation-assertability argument never licenses narrowing a contract; it
can only ask whether the contract was stated at the right width.

The error documentation said "deeper than 16 levels" a round after the
bound became two, and now states the shipped one.
`resolve_pointers` remembered the offsets it had followed in a `Vec` and
tested membership by scanning it. Nothing bounds how many offsets one
pointer array holds — the count is a 32-bit field the file chooses — so
the walk was quadratic in a number hostile input picks, on a surface the
README calls hardened against hostile input.

Measured in release on hand-built classic TIFFs whose single `ExifIFD`
array names N distinct all-zero directories, every call returning `Ok`:
0.24 s at 0.59 MB, 0.98 s at 1.17 MB and 4.23 s at 2.34 MB — clean
quadratic growth. With a `BTreeSet` the same three files answer in
5.2 ms, 13.6 ms and 26.9 ms.

`insert` returning `false` *is* the loop guard, so the guard loses a
line rather than gaining one. The shared walk this one restates has the
same defect and is fixed separately (#578); what still has no bound at
all is the breadth of a single pointer array (#579).
The previous commit dropped `max_store_len` because its comparison had
an accepting side no test could allocate. One of the two bounds it
carried was standing in for something real: classic TIFF counts an
`UNDEFINED` value with a 32-bit `LONG`, so a store past 4 GiB cannot be
described whatever else holds it. Without the check a reservation that
size is allocated, zero-filled and the whole image compressed before
`gamut_ifd::c2pa::append_store` refuses it — with a message about the
file's 4 GiB offset limit rather than about the length the caller
passed. The documentation of a refusal that still happens went with it.

The bound is back, expressed as `u32::try_from` over the variant rather
than as a comparison, so there is no boundary whose accepting side needs
4 GiB to assert. It is deliberately *necessary*, not sufficient: whether
the store's own offset fits depends on the size of the file it lands
after, which only `append_store` knows, and that refusal stays there.

The `isize::MAX` reservation test now asks for BigTIFF, since classic
TIFF refuses that length one check earlier for a different reason.
`a_broken_pointer_on_a_page_the_metadata_discards_does_not_fail_the_read`
named one rule and asserted four things: that the read survives, that
IFD 0's XMP comes back, that the last page's C2PA store comes back, and
that the pixels decode. Three modules could fail it, and the store
assertion killed nothing the inline
`read_metadata_takes_the_store_from_the_last_ifd_of_the_chain` does not
already kill.

It now asserts the rule it is named for and nothing else: the call
succeeds and still returns IFD 0's blocks. The fixture loses the store
it no longer reads, which drops the file's last reach into the C2PA
locator and the strip decoder.
`pointer_offsets` restates `gamut_ifd`'s rule for what a sub-IFD pointer
value is, and the source guards its 64-bit arm with
`#[cfg(feature = "bigtiff")]` while this copy does not. That reads as a
dropped guard; it is not one, and it cannot be restored. `bigtiff` is
`gamut-ifd`'s feature, enabled unconditionally by this crate's
dependency on it and not re-exported, so the attribute here names a
feature `gamut-tiff` does not have: `unexpected_cfgs` rejects it under
the workspace's `-D warnings`, and were it accepted the arm would vanish
and every BigTIFF's `ExifIFD` would read back as a plain integer.

Verified by adding the attribute and building: one
`unexpected cfg condition value: bigtiff` warning, which the lint gate
takes as an error.
CI's incremental mutation shard timed out on `delete !` in
`c2pa_store` — the guard added one commit earlier, inverted into its own
opposite. Deleting the `!` lets the test's oversized reservation reach
`zeroed`, whose `try_reserve_exact` the runner's allocator granted, and
the 4 GiB zero-fill that followed ran past the 60 s test timeout. The
same mutant was CAUGHT locally, on a machine that zero-fills 4 GiB
inside a minute: the mutant's fate depended on the hardware, which is
not a gate.

Two changes, neither of them a weakened check. The condition is spelled
as the refusing one, `uncountable`, so there is no `!` to delete. And
the test asks for `usize::MAX` rather than `u32::MAX + 1`, so a length
that gets past any future guard meets a reservation no allocator can
satisfy and fails instantly instead of allocating. Which of the two
bounds answers first is now what separates this test from the BigTIFF
one, since both ask for the same length.
Round 1 followed `STANDARD_POINTER_TAGS` everywhere and round 3
narrowed the list to `ExifIFD` and `InteroperabilityIFD` so that a
dangling `SubIFDs` offset on a page could not hide the blocks. The
narrowing was right about the page and wrong about the directory: it
also stopped resolving `SubIFDs` and `GPSInfo` *inside* the Exif
directory, which is the one directory the seam hands back. Executed at
the previous head, a `GPSInfo` group under `ExifIFD` encodes with no
anomalies, comes back from `metadata()` as `Long([212])` — a raw
absolute offset into the source file — and re-encoding that value makes
this crate's own `deconstruct` report a `Severity::Error` structural
anomaly on a file that is no longer fully classified.

The two levels answer opposite questions, so the tag list is now
per-level rather than one flat list. At IFD 0 a followed pointer can
only add a failure mode, so only `ExifIFD` is followed there and round
3's finding stays closed. Below it an *un*followed pointer becomes a
stale offset in a directory the caller is handed, so all four standard
pointer tags are followed. The cost is stated where the list is: inside
the subtree an unreadable target under any of the four now fails the
read, which is the trade `ExifIFD` itself already made.

The writer's bound follows the reader's, and splits into the two
refusals it always conflated. A group under a tag outside the standard
set is refused by its own message instead of one citing the Interop
depth clause, which was the wrong clause for that mistake; a group
nested past the reader's depth keeps the depth message. Neither counts
a childless group, so the depth bound still counts children rather than
groups.
`exif_ifd` filtered on `fields()` alone, so a directory holding nothing
but a sub-IFD group counted as empty and was dropped: it encoded to a
file with no Exif directory at all, and read back as absent, with no
error to say so. That shape is not hypothetical — it is exactly what
`read_metadata` returns for an Exif directory whose only entry is its
`InteroperabilityIFD` pointer, so a decode/re-encode round trip lost the
directory silently.

A group is one on-disk entry, so a directory holding one is not an empty
directory. The filter now accepts either, and `is_empty` says so.
This PR restructured the encoder so that every entry point resolves the
C2PA store and the metadata check at one chokepoint, before any pixel
work. Nothing in the suite held that: reverting `encode_palette8` alone
to the unchecked form and running the whole crate suite failed zero
tests, because the per-path tests added in round 4 pin which store is
resolved rather than that it was checked at all — a path that drops the
check still refuses a bad C2PA store.

The claim is about entry points, so the table is over entry points: all
twelve public encode surfaces, given an Exif tree the decoder could not
read back, each asserted on the refusal's message rather than on
`is_err` so that a surface refusing for an unrelated reason is not
mistaken for one honouring the bound. It replaces the single-surface
test it subsumes. Verified against the regression it exists for: with
`encode_palette8` reverted, it is the only test in the crate that fails.
The guard refusing a store no classic TIFF `count` word could describe
was two lines inline, and its *magnitude* was unpinned: narrowing the
conversion to sixteen bits fails zero tests, because every fixture is
far below either bound. The mutation gate was satisfied only because
substituting true and false for the whole condition is caught by other
assertions — neither of which can see a bound that is merely wrong.

No test going through `c2pa_store` can reach the boundary either: a
length large enough to be refused there is large enough that `zeroed`'s
reservation answers first, which is what the previous commit series
deliberately arranged so the mutant could not time out. So the bound
moves into a named predicate and is asserted directly, at `u32::MAX` and
one past it, plus BigTIFF's freedom from it — three assertions, no
allocation. Spelling it as the refusing condition keeps the call site
free of a `!` for a mutant to delete.
Three places asserted a round trip cannot emit a stale offset — the
README's "what the encoder writes the decoder reads back", the status
file's "the writer is bounded by what the reader accepts", and the
module docs. Two of the four standard pointer tags did not hold it, so
all three said more than was true.

They now state the rule that is actually implemented: which pointers are
resolved depends on the level, `ExifIFD` alone at IFD 0 and all four
standard tags inside the returned Exif directory, with the cost of the
second named rather than left implicit — an unreadable target under any
of the four fails the read. The vendor-private caveat the crate already
carried is extended to say plainly that such a field is not round-trip
safe and that a round trip through it proves nothing. The writer's bound
is restated as the two refusals it now is, tag and depth, each with its
own message.

Also rewraps the paragraphs this branch introduced to the ~100 columns
the surrounding files use, instead of the 110-125 they had drifted to.
…s count

Self-review of the previous commit: the new test cloned the Interop
directory it built and then compared only `sub_ifds().len()`, so the
clone was needless and the assertion could not tell the written group
from any other group of one. Comparing against the directory itself is
both stronger and shorter.
`check_exif_subtree` inspected a directory's sub-IFD *groups*; `resolve_pointers`
inspects its *fields*. That asymmetry was the defect: the one shape the reader
misreads was the one shape the writer never looked at.

A standard pointer tag carried as a plain `LONG` — `SubIFDs` (330), `ExifIFD`
(34665), `GPSInfo` (34853) or `InteroperabilityIFD` (40965) — passed the check,
encoded cleanly, and then failed this crate's own `metadata()` with `read out of
bounds` for a large integer or `sub-IFD pointer loop` for a small one. That is
precisely the file `TiffMetadata::check` documents it exists to prevent: one
"read back as something other than what was written".

The refusal now inspects what the resolver inspects, at every level of the
subtree, and is shaped by the value's *type* rather than by its tag:
`pointer_offsets` accepts only LONG/IFD/LONG8/IFD8, so a `SHORT` under `SubIFDs`
is a pointer to neither side and is written and read back unchanged.

Both refusal messages become named constants so the tag set the public contract
enumerates in prose has one place that fails when it stops being true — the set
itself is a sibling crate's constant, and a fifth member arriving upstream would
otherwise widen the contract silently.
The set of sites was derived by sweeping the crate for the contract's own terms
— every `///`/`//!` line naming a pointer tag by name or number, every one
naming the follow/resolve contract, the crate's markdown, and the error-message
strings — rather than by listing them from memory. That sweep found five
restatements of this contract and two that had gone false:

`TiffDecoder::metadata` still said only `ExifIFD` and `InteroperabilityIFD` are
followed, which was false in both directions: a dangling non-listed tag *inside*
the Exif directory now fails the call, which the doc implied cannot happen, and a
dangling `InteroperabilityIFD` at IFD 0 now succeeds, which the doc said is
followed. It now states the per-level rule and what each level costs.

`TiffEncoder::with_metadata` said nesting is "the one thing" that could break the
agreement between what it writes and what the decoder reads. There are three, and
each has its own message; all three are now named there, in README.md and in
STATUS.md.
`a_standard_pointer_at_ifd_0_that_feeds_no_field_is_left_alone` passed under the
exact defect its comment describes — `pointer_tags` returning the full set at
every level. Its first assertion called `read`, which resolves no pointer under
any configuration, and its second asked for `exif`, which stays `None` whether a
`SubIFDs` pointer at IFD 0 was resolved or not. The matching mutant was caught,
but by the other half of the rule, so that axis was green for the wrong reason.

It now drives `resolve_pointers` directly at the depth `read_metadata` calls it
with, and asserts the field stays the integer it was read as and becomes no
group — which resolution at IFD 0 would break. Verified: the previous body
passes under that change and this one fails, as its only failure.

Also names `IFD0_POINTER_TAGS`, which is what the round-6 split left of the
`POINTER_TAGS` a neighbouring comment still referred to in the present tense.
`check_exif_subtree` asked `pointer_offsets` about a caller's `Value`, so it
classified by the in-memory variant. The reader classifies by the type code the
entry carries, and `Value::Unknown` is the one shape where the two disagree: its
constructor validates only the value word's width, so an `Unknown` built at code
4, 13, 16 or 18 is a plain field to the variant-shaped predicate and a sub-IFD
pointer to the reader. `write` emits the code verbatim, and 64 of 384 end-to-end
cases encoded cleanly and then failed this crate's own `read_metadata` with
`read out of bounds` or `value offset out of bounds`, or -- in BigTIFF at the top
level -- read back as a group where a field was written.

The writer now asks what the reader asks: `Value::type_code`, total over every
variant, against `POINTER_TYPE_CODES`. That membership is pinned against
`pointer_offsets` by sweeping the whole `u16` code space rather than by
repeating four numbers, and the boundary itself is swept by a matrix derived
from the type -- every representable entry type, natural and `Unknown`, across
the four pointer tags, classic and BigTIFF, on the Exif directory and one level
below it.

The sibling half, a `gamut-ifd` constructor that admits a recognised code into
`Unknown` at all, is #608 and is not fixable from this crate.

Refs #446
`Ifd` keeps fields and sub-IFD groups in two lists, so one tag can sit in both.
The writer then emitted two entries under that tag -- not a TIFF directory
(TIFF 6.0 §2) -- and the field was silently lost: this crate's model collapses a
duplicated tag to its last occurrence, and the group is written last. Verified
on disk: an Exif directory given `InteroperabilityIFD` as both a `SHORT` field
and a group encoded to entries `[(33434, 5, 1), (40965, 3, 1), (40965, 4, 1)]`,
and read back without the field.

Three documentation sites promised the opposite unconditionally -- that what the
caller supplies is what the file gets. The encode now refuses the pair with its
own message, because the alternative is a non-conformant file plus a reader that
drops a field the caller set, and normalising it would mean choosing silently
which of the two the caller meant. A non-minimal writer is the cheaper cost.

Refs #446
`deconstruct` is this crate's own judge, and it graded a file carrying two
entries under one tag clean -- which is why a round trip could not see the
writer that emitted one. The report walked the parsed tree, and `Ifd` is a
directory model: by the time the tree exists a repeated tag has already
collapsed to its last occurrence, so the defect is invisible there by
construction.

The scan now re-reads the raw entry records of every directory the audit walked
(`SpanKind::IfdBody`), a second pass over bytes already claimed rather than a
second walk of the pointer graph, and reports `Anomaly::DuplicateTag` with the
directory's offset, the tag and the count. `Anomaly` is `#[non_exhaustive]`, so
the variant is additive.

Which entry survives a repeated tag is a property of the reader, not of the
file: this crate keeps the last occurrence, libtiff marks every occurrence after
the first to be ignored and warns the directory is unsorted.

Refs #446
The scoping decision -- IFD 0 resolves `ExifIFD` and nothing else -- was held by
a test that iterated a hand-written list of the two tags it expected to be left
alone. Adding the fourth standard pointer tag to `IFD0_POINTER_TAGS` therefore
left all 25 of this crate's test binaries green, and a `const`'s contents are
not a mutable expression, so the mutation gate cannot see it either.

The membership is now asserted directly and the sweep derived from the constant
by difference, so it can never claim "left alone" about a tag the constant says
is followed. Verified: the widening now fails this test and only this test.

Refs #446
Nothing in this pull request consulted the crate's oracle about the seam, and a
repeated tag is exactly the defect a round trip cannot see: gamut writes and
reads by one rule, so a rule that is wrong is wrong symmetrically.

libtiff marks every occurrence after the first to be ignored and warns that the
directory is unsorted; this crate's directory model keeps the last. A hand-built
2x2 RGB TIFF carrying `PhotometricInterpretation` twice -- `RGB` then
`BlackIsZero` -- is therefore decoded as RGB by libtiff and reported as
`BlackIsZero` by `TiffDecoder::info`. That disagreement is the whole reason the
encode refuses a tag carrying both a field and a group, and the reason
`deconstruct` grades one.

The fixture is built byte by byte because no directory model can express two
entries under one tag, which is the normalisation the test exists to look
underneath.

Refs #446
…re each is claimed

Five sites state this seam's writer/reader bound, derived by sweeping the crate
for the contract's own terms rather than listed by hand: `with_metadata`,
`TiffDecoder::metadata`, `TiffMetadata::exif`, README.md and STATUS.md. Three of
them promised unconditionally that what the caller supplies is what the file
gets, which a tag carrying both a field and a group falsified, and all of them
named the refused set by the in-memory `Value` variant, which `Value::Unknown`
falsified.

Each now says what the code does: the discriminator is the on-disk type code,
and a tag may carry a field or a group but not both. The STATUS paragraph round 7
left at 152 columns is rewrapped with the rest; no line this branch adds to
README.md or STATUS.md now exceeds 100 columns, measured rather than asserted.

Refs #446
The fixture repeated `PhotometricInterpretation`, but the oracle harness
never queries that tag and for uncompressed chunky data the scanline bytes
do not depend on it: with the two entries swapped, libtiff's decode is
byte-identical, and with libtiff patched to keep the *last* duplicate the
test still passes. Only this crate's half was load-bearing, so the test
could not fail for the disagreement it is named for.

Repeat `StripOffsets` instead, with the two entries pointing at two
different strips that are both in the file. The scanline bytes are then a
function of which entry the reader keeps: libtiff returns the first entry's
strip, this crate the last, and the assertion fails if either reader
changes its rule. Both orderings are built, so "follows the first entry" is
separated from "follows the lower offset".
…rries

`Anomaly::Structure` reports a `Severity`; `Anomaly::DuplicateTag`, added in
this pull request, did not, so a caller triaging a report had to know which
variants carry one and infer the rest. A repeated tag is a structural defect
of the same kind — TIFF 6.0 §2 gives a directory one entry per tag, and the
field a caller set is dropped by at least one reader — so it is graded
`Severity::Error`.

Added now rather than later because the variant is new and unreleased: the
same field arriving afterwards would be a breaking change to a `#[non_exhaustive]`
match that binds it. `flags_a_directory_that_repeats_a_tag` matches the
severity, so grading it a warning fails that test alone.
`check_duplicate_tags` walks the audit's `IfdBody` spans and `continue`s past
anything it cannot re-read, on the recorded ground that such a directory "is
already reported by the audit's own finding". That is a contract a caller may
rely on — a file is never graded fully accounted on the strength of a
directory nobody read — and nothing failed when it stopped being true.

State it precisely at the site, separating the two silent skips: a span the
audit claimed re-parses by construction, so that arm is unreachable; a
directory the audit could not parse is claimed as no span at all and arrives
as `AuditFinding::SkippedSubIfd`. Pin the second with a file whose `SubIFDs`
target lies past the end, the plain-unreadable reason that neither the cycle
nor the depth guard produces, and which no test reached before.
…n the reader

`TiffMetadata::check` refuses a sub-IFD group hung off a tag outside the four
the reader resolves, while the reader takes such a file without error and
hands the tag back as the raw absolute offset it was written as — the child
directory is lost silently. That asymmetry is the stated reason refusing is
the conservative choice, and it was stated only in prose: nothing failed if
the reader began resolving the tag, and nothing failed if it began refusing
it, either of which would remove the reason.

Drive the reader over a file the writer would refuse, built through
`gamut_ifd::write` so `check` is not in the path, and assert both halves — the
tag comes back as a bare offset, and no group carries it.
The comment said the sweep is derived "from the constant" beside a paragraph
about `IFD0_POINTER_TAGS`, while the code iterates
`gamut_ifd::tags::STANDARD_POINTER_TAGS`. Same members — this crate's
`EXIF_SUBTREE_POINTER_TAGS` is defined as that constant — but a repair whose
point is deriving a set from the constant it names should name the right one.

Say which, and why the sibling crate's constant is the stronger choice: the
domain the sweep must cover is every standard pointer tag that exists, so a
fifth added upstream has to enter it whatever this crate's alias does, and
deriving from the alias would let a later narrowing shrink the sweep silently.
Self-review of the previous commit: the two strips are laid out back to back
and the second one's offset was written as `+ 12`, the first strip's length as
a literal, while `StripByteCounts` derived the same number from the array. The
"value layout drifted" assertion only covers the first strip, so widening the
pixel blocks would have overlapped them silently. Both now come from one
binding.
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