Skip to content

feat(exif): DC-008 tag breadth, per-tag value shape, and value descriptions - #529

Open
justin13888 wants to merge 19 commits into
feat/419-exif-streaming-readerfrom
feat/417-exif-tag-breadth
Open

justin13888 wants to merge 19 commits into
feat/419-exif-streaming-readerfrom
feat/417-exif-tag-breadth

Conversation

@justin13888

@justin13888 justin13888 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #522 (feat/419-exif-streaming-reader). Review only the commits above that base; merge #522 first.

Summary

gamut-exif catalogued 153 tags as (IfdKind, id, name) triples. ExifTag::name() was the only
string in the crate, nothing mapped a tag to the field type and component count CIPA DC-008
requires, and nothing rendered an enumerated value. Issue #417 named all three gaps.

All three are closed, staying inside the vendored specification (references/exif, CIPA DC-008 /
Exif 3.0):

Breadth. A census of DC-008 against the catalogue found the crate was missing exactly seven
tags the spec defines: the Exif 3.0 authorship/software block ImageTitle, Photographer,
ImageEditor, CameraFirmware, RAWDevelopingSoftware, ImageEditingSoftware,
MetadataEditingSoftware (0xA4360xA43C, Table 8 section I, §4.6.6.9.8–§4.6.6.9.14). With
those added the catalogue covers every one of the 151 tags DC-008 itself defines — Table 6
(30, 0th IFD), Tables 8 and 9 (88, Exif sub-IFD), Table 14 (32, GPS), Table 16 (1,
Interoperability) — plus nine carried from other specifications for compatibility, for 160 in all.

The issue's headline number (exiv2's 408) is not reached, and deliberately so: the remaining
~248 are tags DC-008 does not define (TIFF/EP, DNG, vendor lineages). Transcribing them means
vendoring their specifications, not copying exiv2's table, which would put an oracle where the
source of truth belongs. Filed as #532 with the design question it raises first (those tags may
belong to gamut-tiff/gamut-dng/gamut-ifd, not here).

Value shape. Every row now carries the Type and Count columns of the DC-008 table that
defines it, as ExifTag::field_types() and ExifTag::component_count(). set_tag_checked refuses
a value that contradicts them. Exif::set_tag and the whole read path are unchanged — real
files break the spec routinely and a caller reproducing one must still be able to. For the nine
non-DC-008 tags field_types() is empty: no constraint is claimed rather than one invented.

Descriptions. A new describe module renders the enumerated tags in DC-008's own wording, and
flash decomposes the Flash bitfield of §4.6.6.7.21 Figure 17. Behind an opt-in describe
feature, default off; no existing build turns it on.

One spec defect found and fixed

GainControl (0xA407): DC-008 Table 9's Type column says RATIONAL, but §4.6.6.7.41 says
SHORT and enumerates five integer codes (0–4). A fraction cannot carry an enumeration, and both
exiv2 and ExifTool read it as SHORT, so the summary table is an erratum. Had it been transcribed
as written, the validating setter would have refused the only values the tag can hold. Found by
mechanically cross-checking all 149 specified rows against the per-tag Tag = / Type = / Count =
blocks the spec states beside each definition; it was the only contradiction between the two.

How the names are checked

Not against a hand-written list, which would only say the table equals itself. tests/oracle.rs
writes every catalogued tag into a stream and asks exiv2 for Exif.<group>.<name>. All 160 resolve
except three, which the test pins as an exact set, with a second test proving each is a naming
difference rather than a missing tag by reading the same value back under exiv2's own spelling:

Tag this crate (CIPA DC-008 / TIFF-EP) exiv2 (vendored v0.28.8)
0x8827 PhotographicSensitivity (renamed in Exif 2.3) ISOSpeedRatings (Exif 2.2)
0x02BC ApplicationNotes XMLPacket
0x83BB IPTC-NAA IPTCNAA

DC-008 wins in each case and both readings are recorded in STATUS.md. exiv2 also resolves all
seven newly added Exif 3.0 tags — independent confirmation of their ids and names.

Where the nine non-DC-008 rows come from

The workspace rule is that a row comes from a specification under references/. Three of the nine
do; six do not, and are named as residuals — carried because real files and every other reader
use them, with no vendored text fixing a type or count, which is why their field_types() is empty
and set_tag_checked constrains nothing. All nine predate this branch; only the disclosure is new.

Tag Vendored source
ApplicationNotes 0x02BC references/xmp/xmp-part3.pdf — TIFF table: 700 / 0x2BC — XMP packet
IPTC-NAA 0x83BB references/xmp/xmp-part3.pdf — same table: 33723 / 0x83BB — IPTC dataset; the payload's own format is references/iptc/iim-4.2.pdf
InterColorProfile 0x8773 references/icc/icc.1-2001-04.pdf — "The TIFFTag that identifies the field = 34675(8773.H)"
InteroperabilityVersion 0x0002, RelatedImageFileFormat 0x1000, RelatedImageWidth 0x1001, RelatedImageLength 0x1002 Residual. DC-008 3.0 names all four in its original-preservation-image annex, then delegates them to "Table 13, 'Interoperability IFD Description Support Levels,' of DCF[2], section 4.7". DCF (CIPA DC-009) is not vendored.
Rating 0x4746, RatingPercent 0x4749 Residual. Microsoft Windows photo-metadata extension; no vendored specification at all.

Vendoring DCF would settle four of the six. Recorded in STATUS.md and as decision 8 below.

What this PR does not prove

Two narrowings, stated plainly because both reduce what the change is evidence for:

  1. The descriptions are not differentially checked. The record asked for a differential per tag
    against exiv2's interpreted value. tooling/exiv2-oracle exposes Exiv2::Exifdatum::toString()
    — the raw serialised value, "1" for Orientation — and has no entry point reaching
    Exifdatum::print(), which is the interpreted one, "top, left". So the names are checked
    against exiv2 for all 160 tags, and the descriptions are transcribed from CIPA DC-008 and
    checked structurally instead: every table ascending with no repeated code, describe exactly a
    lookup into described_values so "reserved" has one definition, the enumerated-tag set pinned,
    and each Flash bit field proved a function of its own bits across all 128 assigned
    combinations. Those falsify a transposed or mis-masked table; they cannot falsify a
    self-consistently wrong transcription. Only an oracle can. Filed as tooling/exiv2-oracle: expose Exifdatum::print() so a rendered-value differential is possible #533; decision 5 below.
  2. The issue's headline number is not reached. "153 → 408" is not closed here. 408 is exiv2's
    standard-tag count; this PR closes the catalogue against CIPA DC-008 (151 of the 160 rows), and
    the ~248 remaining are tags DC-008 does not define. Filed as gamut-exif: tag breadth beyond CIPA DC-008 (the remaining ~248 tags exiv2 knows) #532, which names where they
    come from — TIFF/EP, DNG, and vendor lineages — and the design question of whether they belong
    in this crate at all rather than in gamut-tiff/gamut-dng/gamut-ifd. The PR is therefore
    Refs #417, not Closes.

Validation

Every command was run in this worktree at the head commit. mise run fmt/fmt-check need the
__CARGO_TEST_ROOT prefix in a nested checkout (cargo otherwise walks past the
worktree root to the primary checkout's manifests); that is an environment artefact, not a change
to any manifest.

Command Outcome
git submodule update --init --recursive ok
mise run fetch-av1-oracles ok (the workspace gates build the AV1/AVIF oracles)
CARGO_BUILD_JOBS=2 cargo test -p gamut-exif --all-features pass — 115 tests over 6 targets, 0 failed
CARGO_BUILD_JOBS=2 cargo clippy -p gamut-exif --all-targets --all-features pass — no warnings
__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/feat/419-exif-streaming-reader..HEAD pass — "no errors in 9 commits"
mise run check-release-deps pass — "release dependency graph has no dev-only workspace edges"
mise run check-ffi-features pass — "gamut-ffi features in sync with gamut"
cargo doc -p gamut-exif --no-deps and --all-features 2 warnings, both pre-existing on the base (reader.rs:35, stream.rs:7); the third, in a tag.rs line this branch rewrites, is fixed here
mise run lint (workspace, --all-targets --all-features -D warnings) pass — 0 clippy findings in 23m49s (exit 0). The one warning: line is cargo advising that a transitive dependency, proc-macro-error2 v2.0.1, will be rejected by a future rustc; it is pre-existing and unrelated.
mise run test (workspace) pass — exit 0. Result recorded in round 2 below, where the command was re-run at the current head; it had not completed when this row was first written.
GAMUT_MUTANTS_BASE=origin/feat/419-exif-streaming-reader mise run mutants-diff pass — 78 mutants in 14m: 77 caught, 0 missed, 1 unviable. The unviable one is replace flash -> FlashDescription with Default::default(), which cannot compile because FlashDescription has no Default — a structural removal, not a survivor. All 78 are in tag.rs, writer.rs and describe.rs.

Beyond the gates, the transcription was checked three ways against the vendored PDF, since a
hand-transcribed table is the one thing a test cannot falsify on its own:

Check Result
Every catalogued tag name, against the reference implementation 160/160 resolve in exiv2, bar the 3 divergences pinned in tests/oracle.rs and recorded in STATUS.md
Every DC-008 tag name, against the specification's own text 151/151 appear verbatim
Every row's Type and Count, against the per-tag Tag = / Type = / Count = blocks the spec states beside each definition (the tables were transcribed from the summary tables, so this is an independent source) 149 specified rows checked; 1 contradiction foundGainControl, fixed in its own commit — and no others
Every description string in describe.rs, against the specification's own text 136/136 appear verbatim

The last three were one-off cross-checks run against pdftotext output of
references/exif/exif-3.0-dc-008-translation-2023.pdf; they are not committed, because they would
put a pdftotext dependency in the test tier for a check that only has to hold at transcription
time. The committed guards against later drift are the exiv2 name differential, the
only_the_non_dc008_tags_claim_no_field_type and exactly_the_enumerated_tags_have_a_table
boundary tests, and the GainControl pin.

Run in a nested worktree. mise run fmt/fmt-check
need the __CARGO_TEST_ROOT prefix in a nested worktree — cargo otherwise walks past the worktree
root to the primary checkout's manifests and the task exits 101 on an untouched tree. That is an
environment artefact; no manifest was changed to work around it. Workspace-wide gates ran inside
systemd-run --user --scope --slice=agents.slice -p MemoryMax=16G -p MemorySwapMax=0 with
CARGO_BUILD_JOBS=2 and ulimit -v, per the run's resource rules.

No failure in this run was caused by this branch. The two rustdoc warnings are classified
pre-existing on the evidence that they sit on lines this diff does not touch — reader.rs:35
and stream.rs:7 are unchanged from the base — rather than on a separate gate run of the base.

No pre-existing failure was observed on the base.

Round 2 — review repairs

An independent review of this branch confirmed all six of the claims above and raised two Medium
findings and four Lows. Every one is repaired in the two commits 284c45d0 and ae353b69. The
base branch had advanced and was merged again — twice, as it kept moving during this round:
first ff6b5711/8c4d3d10 (both in maker_note.rs, neither touching this diff), then
3242ea1b/44594fa1/ba0a139f, which do overlap this diff in README.md and lib.rs. That
second merge (61d5934d) auto-merged with no conflicts — the two sides edit disjoint paragraphs,
the base's in the reader/report prose and this branch's in the catalogue and write-door prose — and
every gate below was re-run at the final head, 8df62326, which is what these results
describe.

Two further commits followed the review repairs, both correcting claims this round had itself
introduced — the same defect class F2 named, caught by re-reading the new text against the spec:

  • 2c15d96f — the rewritten drift-guard doc asserted that the 39 tags with a table here are
    "exactly the tags for which CIPA DC-008 prints a value table". False in both directions.
    Printing a table is not sufficient: §4.6.7.1.1 gives GPSVersionID 2.4.0.0 = Version 2.4 / Other = reserved, §4.6.6.1.2 gives FlashpixVersion 0100 = Flashpix Format Version 1.0 / Other = reserved, §4.6.5.1.12 gives YCbCrSubSampling [2, 1] = YCbCr4:2:2 / [2, 2] = YCbCr4:2:0, and §4.6.8.1.1 gives InteroperabilityIndex "R98" / "THM" / "R03" — none a
    scalar code a (u32, &str) row can carry. Nor is it necessary: §4.6.6.7.28 prints no table for
    FocalPlaneResolutionUnit, defining it as "the same as the ResolutionUnit", and it shares that
    arm. The guard now states the rule the code implements — a single scalar code, per value or per
    ComponentsConfiguration element — and names the tags that decide the boundary. The 39 names it
    pins are unchanged and remain correct. The same commit also drops a false reassurance on
    ExifTag (#[non_exhaustive] blocks exhaustive matching, not ExifTag::Make as u32 on a
    fieldless enum, so "nothing may cast it" was wrong; the warning stays, the reasoning is
    corrected) and narrows the README's pointer-drop claim to its mechanism — writer.rs removes
    each pointer only from its home directory, so one hand-set elsewhere survives as an ordinary
    field — and enumerates the lenient doors as the nine that admit a field, with
    set_thumbnail noted separately as admitting unvalidated bytes.
  • 8df623262c15d96f left the guard doc and STATUS.md each saying "Four tags whose sections
    print one are excluded" above a list of five (Flash, GPSVersionID, FlashpixVersion,
    YCbCrSubSampling, InteroperabilityIndex); the module docs had it right by counting Flash
    separately. Corrected to five.
Command Outcome
CARGO_BUILD_JOBS=2 cargo test -p gamut-exif --all-features pass — 122 tests passed, 0 failed, over 6 targets (lib, golden.rs, oracle.rs, report.rs, streaming.rs, doctests). The rise from 117 is the base's own new report tests arriving with the second merge.
CARGO_BUILD_JOBS=2 cargo clippy -p gamut-exif --all-targets --all-features -- -D warnings pass — no findings
__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/419-exif-streaming-reader..HEAD pass — "no errors in 14 commits"
mise run lint (workspace, --all-targets --all-features -D warnings) pass — exit 0, 0 clippy findings. The one warning: line is cargo advising that the transitive dependency proc-macro-error2 v2.0.1 will be rejected by a future rustc; pre-existing and unrelated.
mise run test (workspace, --all-features) pass — exit 0, 3828 tests passed, 0 failed. The exiv2 differential ran and passed, including exiv2_knows_every_catalogued_tag_by_the_name_gamut_gives_it, so the serialised call sites still hold under the full suite.
GAMUT_MUTANTS_BASE=origin/feat/419-exif-streaming-reader mise run mutants-diff pass — 83 mutants in 3m: 82 caught, 0 missed, 1 unviable. mutants.out/missed.txt is empty; the unviable one is again replace flash -> FlashDescription with Default::default(), which cannot compile.
cargo doc -p gamut-exif --no-deps, --all-features and --no-default-features clean at 61d5934d. Earlier in this round it reported 2 warnings (reader.rs:10, reader.rs:35), both shown pre-existing on the basegit diff <base>..HEAD -- src/reader.rs was empty and both lines were present at the base — and the base's own ba0a139f has since fixed them. This branch's edits added no rustdoc warning at any point. Note mise run lint is clippy only, so rustdoc is not a repository gate; it was run deliberately because this round is mostly documentation.

mise run check-release-deps and mise run check-ffi-features were not re-run in round 2: no
Cargo.toml was touched, which is the condition the run's contract attaches them to. Their round-1
results above still stand at this head.

No failure observed in round 2 was caused by this branch. Workspace gates ran inside
systemd-run --user --scope --slice=agents.slice -p MemoryMax=16G -p MemorySwapMax=0 with
CARGO_BUILD_JOBS=2 and ulimit -v, per the run's resource rules.

What the review found, and what was done

Finding Repair
F1 (Medium)describe returned None for nine GPS reference tags DC-008 does enumerate (GPSLatitudeRef, GPSLongitudeRef, GPSSpeedRef, GPSTrackRef, GPSImgDirectionRef, GPSDestLatitudeRef, GPSDestLongitudeRef, GPSDestBearingRef, GPSDestDistanceRef), while the module documents None as "the spec reserves that code" — so the crate stated something false about the specification. All nine transcribed from §4.6.7.1 of the vendored spec, taking coverage from 30 to the 39 tags for which DC-008 prints a value table. The module doc that named GPSStatus and GPSMeasureMode as the character-coded tags — the sentence that made this an oversight rather than a scoping choice — now names all eleven.
F2 (Medium) — the drift guard exactly_the_enumerated_tags_have_a_table was documented as pinning "exactly which tags CIPA DC-008 enumerates" but pinned the 30 names the table happened to hold, so it asserted a false proposition and blocked F1's fix. List corrected to 39 and the doc reworded to pin what the spec enumerates rather than what the arm list contained.
LowREADME.md claimed "every tag CIPA DC-008 defines", overstating by three: §4.6.3.1.1/.2.1/.3.1 give the Exif, GPS Info and Interoperability IFD pointers their own Tag/Type/Count blocks. README tightened to tag.rs's precise wording (the five tables), and both README and tag.rs now say why the three pointer tags stay uncatalogued.
Lowlib.rs and writer.rs each named one lenient write door where six are public. Both enumerate all six, plus the siblings reaching the same directories.
Lowlib.rs:26 claimed every catalogued tag carries a spec-mandated type and count, false for the nine non-DC-008 rows. Corrected to tag.rs's statement.
Low — 43 pre-existing ExifTag variants silently changed implicit discriminant. Spec ordering kept, and the property is now documented on the type: the discriminants are not stable, tag_id() is the on-disk identity, and the enum is #[non_exhaustive], repr-less and reached by no gamut-ffi entry point.
Low — the Validation table said mise run test was still running. Corrected above.

The review also reproduced the round-1 mutation run exactly (78 mutants, 77 caught, 1 unviable,
missed.txt empty) and verified 127/127 description strings and 10/10 flash strings verbatim
against the spec, so none of the round-1 transcription needed redoing.

One test was added

a_reference_tag_letter_means_what_its_own_section_says. A missing match arm is not a mutant, so
neither the structural checks nor the mutation gate could have seen F1, and neither would see its
recurrence. The class that replaces them: these nine tags reuse the same few letters for unrelated
meanings — 'M' is a magnetic direction, miles per hour, or miles; 'N' is north latitude, knots,
or nautical miles — so an arm that swallowed one tag into a sibling's group would answer plausibly
and wrongly while every existing assertion still held (the domains stay the same size, ascending
and non-empty either way).

Risks and rollout

  • Additive only. ExifTag is #[non_exhaustive], so the seven new variants are a minor
    change. field_types/component_count/TagCount, check_tag/set_tag_checked/
    TagConstraintError, and the whole describe module are new surface; nothing existing changed
    shape.
  • No behaviour change on read. The reader, Exif::parse, Exif::set_tag and ExifWriter are
    byte-for-byte the same. The only new refusal is one a caller must opt into by calling
    set_tag_checked.
  • ExifTag::ALL order. Gamma (0xA500) moved to its sorted position at the end of the Exif
    block. ALL is documented as "in declaration order", so this is not a contract change, but a
    consumer that hard-coded an index would notice.
  • describe is off by default and no workspace crate enables gamut-exif/full, so no existing
    build grows. TagConstraintError is a second error type beside ExifError, following the
    precedent of GpsConversionError.
  • Rollback is per commit: the description module, the validating setter, the two table columns
    and the seven rows are separate commits in that order.

Issue

Refs #417. Not Closes: the issue's headline is exiv2's 408-tag count, and the tags beyond
CIPA DC-008 are filed as #532. The one piece of the stated acceptance that could not be met is
filed as #533.

Decisions taken

No human approved this plan. This is an unattended automation run; the record below is what a human
reads afterwards. Decisions 1–4 are the record this lane was given; 5–8 were appended by the lane
and are marked.

Issue 417 - gamut-exif: standard tag-dictionary breadth + value semantics
Plan:     v1
Branch:   feat/417-exif-tag-breadth
Base:     head of PR #522 (origin/feat/419-exif-streaming-reader, c49f83b) - stacked
Cause:    -
Touches:  gamut-exif tag.rs (rows gain expected FieldType + component count), new describe.rs behind a feature, writer.rs (validating setter), Cargo.toml (feature), lib.rs, tests, STATUS/README
Will not: change the LENIENT READER - it must keep accepting non-conformant real-world files exactly as it does today; decode MakerNotes (#418); touch gamut-ifd; copy any table out of exiv2
Lane:     serialised behind E-419 (stacked)
Settled:  S1 no new dependency; S3 docs/testing.md; ExifTag is #[non_exhaustive] so every addition is minor

Decisions taken.
1. Deliverable boundary
   Taken:    every tag defined in the VENDORED CIPA DC-008, with its spec-mandated FieldType and component count, plus value descriptions for the enumerated tags - this is the issue's own bound ("staying inside CIPA DC-008 ... breadth within the spec already staked"). Where exiv2's 408 includes tags that DC-008 does not define (TIFF/EP, DNG, vendor extensions), those are OUT of this deliverable and are filed as a named remainder rather than transcribed from the oracle
   Rejected: matching exiv2's count by copying its table - it would make an oracle the source of truth, which the workspace forbids, and would import rows no vendored spec defines
   Reverses: drop the added rows
   Filed:    a remainder issue naming the exiv2-known tags outside DC-008, with the count and where they come from
2. Expected type and count
   Taken:    each row gains its spec FieldType and component count; a new VALIDATING setter rejects a value whose type or count contradicts the spec, while the existing setter and the whole read path keep today's leniency
   Rejected: validating on read - real-world files violate the spec routinely and the crate's leniency is deliberate; changing the existing setter's behaviour - a silent breaking change for callers writing non-conformant values on purpose
   Reverses: drop the validating setter and the two columns
3. Value descriptions
   Taken:    behind an opt-in Cargo feature (default off), because a display table is dead weight for an encoder-only consumer and the workspace is allocation-conscious; the feature is additive so no default build grows
   Rejected: unconditional inclusion; a runtime-loaded table (a dependency and a file format for static data)
   Reverses: make the module unconditional
4. Acceptance
   Taken:    a differential against `tooling/exiv2-oracle` per newly added tag - exiv2's canonical name and its interpreted value against gamut's name and rendered description - rather than hand-written expectations; where exiv2 and DC-008 disagree, DC-008 wins and the divergence is recorded in STATUS.md with both readings
   Rejected: asserting against a hand-written table, which would only test that the table equals itself

5. [APPENDED BY THE LANE] The oracle cannot express half of decision 4's acceptance
   Taken:    the exiv2 differential asserts gamut's NAME for all 160 catalogued tags (three divergences pinned as an exact set, each proved a naming difference by reading the same value back under exiv2's spelling). The rendered DESCRIPTION is not differentially checked. The description tables are transcribed from CIPA DC-008 - the source of truth the workspace mandates - and checked STRUCTURALLY instead: every table ascending with no repeated code, `describe` exactly a lookup into `described_values` so "reserved" has one definition, the enumerated-tag set pinned, and each `Flash` bit field proved a function of its own bits and no others across all 128 assigned combinations. Deliverable reduced, remainder filed
   Rejected: adding an `exiv2_exif_print` entry point to tooling/exiv2-oracle/src/{shim.cpp,lib.rs} - both are outside this lane's manifest, and the lane contract forbids widening it; asserting the descriptions against a hand-written expectation, which decision 4 already rejected as testing the table against itself
   Reverses: the structural tests are additive; delete them
   Filed:    #533. A manifest revision request naming the two oracle paths is in the lane report
   Evidence: tooling/exiv2-oracle/src/shim.cpp's exiv2_exif_get calls `pos->toString()` - the raw serialised value - and no shim entry point reaches `Exiv2::Exifdatum::print()`, which is exiv2's interpreted value. The lane prompt's "Verified current state" stated the oracle "exposes exiv2's canonical name and its interpreted value for every tag it knows"; the first half is true and the second is not

6. [APPENDED BY THE LANE] CIPA DC-008 contradicts itself on GainControl (0xA407) - the spec disagrees with the spec, not gamut with the spec
   Taken:    SHORT, from the tag's own section, over the summary table's RATIONAL. The two clauses, verbatim from `references/exif/exif-3.0-dc-008-translation-2023.pdf`:
               Table 9 "Exif IFD Attribute Information (2)", row:
                 "Gain control    GainControl    41991 A407    RATIONAL    1"
               Section 4.6.6.7.41 GainControl, "This tag indicates the degree of overall image gain adjustment.":
                 "Tag  = 41991 (A407.H)"   "Type = SHORT"   "Count = 1"   "Default = None"
                 "0 = None"  "1 = Low gain up"  "2 = High gain up"  "3 = Low gain down"  "4 = High gain down"  "Other = reserved"
             A RATIONAL cannot carry an enumeration of five integer codes, so the section is the operative clause and the table's Type cell is an erratum; exiv2 and ExifTool both read the tag as SHORT. Recorded in STATUS.md under "Where CIPA DC-008 disagrees with itself" - where a maintainer looks - and pinned by `gain_control_follows_its_own_section_not_the_summary_table` so it is not "corrected" back to the table
   Rejected: following Table 9, the column every other type in the catalogue was transcribed from - `set_tag_checked` would then have refused every value section 4.6.6.7.41 defines, i.e. all of them; silently carrying both readings, which would leave the next reader unable to tell a transcription slip from a spec defect
   Reverses: one row in tag.rs
   Evidence: found by mechanically cross-checking all 149 specified rows against the per-tag `Tag = / Type = / Count =` blocks the specification states beside each definition - an independent source from the summary tables the catalogue was transcribed from. It is the only contradiction between the two in the whole catalogue

8. [APPENDED BY THE LANE] Six of the nine non-DC-008 rows rest on no vendored specification
   Taken:    name them as residuals rather than leave the phrase "carried from other specifications" unchecked. Three of the nine are grounded: ApplicationNotes (0x02BC) and IPTC-NAA (0x83BB) in `references/xmp/xmp-part3.pdf`, whose TIFF table gives "700 0x2BC XMP packet" and "33723 0x83BB IPTC dataset"; InterColorProfile (0x8773) in `references/icc/icc.1-2001-04.pdf`, "The TIFFTag that identifies the field = 34675(8773.H)". Six are not: DC-008 3.0 names InteroperabilityVersion / RelatedImageFileFormat / RelatedImageWidth / RelatedImageLength in its original-preservation-image annex but delegates them to "Table 13 ... of DCF[2], section 4.7", and DCF (CIPA DC-009) is not vendored; Rating (0x4746) and RatingPercent (0x4749) are a Microsoft extension with no vendored text at all. Their `field_types` is empty for exactly that reason - no text here fixes a type or count, so none is claimed, and `set_tag_checked` constrains nothing
   Rejected: inventing a type and count for them from what other readers do, which would put an implementation where a specification belongs; deleting the rows, which would lose names real files carry and every other reader knows
   Reverses: nothing - the rows and their empty type lists predate this branch; only the disclosure is new
   Filed:    not filed as its own issue: vendoring DCF would settle four of the six and is named in STATUS.md as the way to close it

7. [APPENDED BY THE LANE] The new differential exposed a race in the exiv2 oracle
   Taken:    serialise this file's exiv2 calls behind a test-local mutex, mirroring the lock `exiv2-oracle` already takes for its XMP entry points, and file the durable fix against the oracle crate itself
   Rejected: leaving it and retrying the check - a nondeterministic SIGSEGV in a required gate is a defect, not flake to be waited out; collapsing the five tests into one to remove the concurrency - that trades a real defect for a test that names five things; editing tooling/exiv2-oracle/src/lib.rs, which is outside this lane's manifest
   Reverses: delete the lock and the three wrapper fns at the top of tests/oracle.rs
   Filed:    #536
   Evidence: on one commit, mutation shard 0's baseline succeeded and caught 20/20 of its mutants while shard 1's baseline died with SIGSEGV before any test reported - identical tree, identical exiv2 build, so the failure is a race rather than a bad input. `tests/oracle.rs` is the workspace's only caller of the oracle's EXIF entry points and goes from two concurrent `#[test]`s to five in this branch; `exiv2-oracle` guards its XMP entry points against exactly this hazard and leaves the EXIF ones unguarded, with nothing recording that as a decision
8. [APPENDED BY THE LANE, ROUND 2] Nine GPS reference tags DC-008 enumerates had no `describe` arm
   Taken:    transcribe all nine from the vendored spec so `describe` covers all 39 tags for which DC-008 prints a value table, correct the module doc that named `GPSStatus` and `GPSMeasureMode` as the character-coded tags, and correct the drift guard that pinned the 30-name list
   Rejected: treating the 30 as a deliberate scope - the module doc's own wording defines `None` as "the spec reserves that code", so silence here is a false statement about the specification, not a smaller promise; leaving the guard and adding an exception list - it is documented as pinning what the spec enumerates, and that is the proposition worth asserting
   Reverses: revert commit 284c45d0
   Evidence: §4.6.7.1.2/.4/.13/.15/.17/.20/.22/.24/.26 each enumerate their codes in exactly the shape §4.6.7.1.9 (`GPSStatus`) and §4.6.7.1.10 (`GPSMeasureMode`) use, both of which are covered. `describe(GpsSpeedRef, u32::from(b'K'))` was `None` where the spec says "Kilometers per hour". A missing match arm is not a mutant, so the class is invisible to both the structural checks and the mutation gate

9. [APPENDED BY THE LANE, ROUND 2] The three IFD-pointer tags stay out of the catalogue
   Taken:    leave `Exif IFD Pointer` (34665), `GPS Info IFD Pointer` (34853) and `Interoperability IFD Pointer` (40965) uncatalogued and tighten README to `tag.rs`'s precise wording, saying at both sites why
   Rejected: cataloguing them so the README's "every tag CIPA DC-008 defines" becomes true - that would let `set_tag_checked` accept a pointer the writer then discards, which is a worse contract than having no name for it
   Reverses: revert the README and `tag.rs` doc hunks of ae353b69, then add three rows to `exif_tags!`
   Evidence: §4.6.3.1.1, §4.6.3.2.1 and §4.6.3.3.1 give each its own Tag/Type/Count block outside the five tables. `writer.rs` calls `image.remove(EXIF_IFD_POINTER)` / `remove(GPS_IFD_POINTER)` and `e.remove(INTEROP_IFD_POINTER)` before synthesising them from the tree, and `writer.rs:724-732` pins that a hand-set pointer does not survive a round trip

10. [APPENDED BY THE LANE, ROUND 2] `ExifTag` keeps spec ordering; the discriminants are documented as unstable
   Taken:    keep variants in the specification's order (so `ALL` iterates it) and document on the type that the discriminants are not stable, that `tag_id()` is the on-disk identity, and that nothing may cast the enum or match it across an ABI boundary
   Rejected: freezing the declaration order and appending new tags at the end - that costs `ALL` its spec order, which every consumer rendering a catalogue relies on, to protect a number no consumer has
   Reverses: revert the `tag.rs` enum-doc hunk of ae353b69
   Evidence: the enum is `#[non_exhaustive]`, carries no `repr`, and no `gamut-ffi` entry point reaches it, so the workspace's C-portability convention (fieldless enums with an explicit `repr` and permanent append-only discriminants) does not bind it

11. [APPENDED BY THE LANE, ROUND 2] `set_tag_checked` keeps refusing a RATIONAL `GainControl`
   Taken:    accept that the checked setter refuses what Table 9 literally permits, and document the contradiction on the setter itself, naming `Exif::set_tag` as the escape hatch
   Rejected: widening `GainControl` to accept both types - a fraction cannot carry an enumeration, and a checked setter that accepts both checks nothing; silently keeping the divergence undocumented - a caller reproducing a Table-9-conformant file would have no way to know why the write failed
   Reverses: revert the `set_tag_checked` doc hunk of ae353b69
   Evidence: Table 9's Type column says RATIONAL for 0xA407 while §4.6.6.7.41 says SHORT and enumerates five integer codes (0-4); the reviewer corroborated SHORT in-tree at `third_party/exiv2/src/tags_int.cpp:2017`. `tag.rs`'s `gain_control_follows_its_own_section_not_the_summary_table` pins the reading

12. [APPENDED BY THE LANE, ROUND 2] `TagConstraintError` stays a parallel error type
   Taken:    keep it separate from `ExifError` and say on the type that this is deliberate, naming the `GpsConversionError` precedent
   Rejected: folding it into `ExifError` as a variant - that would make every caller who only reads match a variant that can only arise on write
   Reverses: revert the `TagConstraintError` doc hunk of ae353b69
   Evidence: `gps::GpsConversionError` already makes the same split in this crate, and nothing recorded why

13. [APPENDED BY THE LANE, ROUND 2] Two gaps are filed rather than closed here
   Taken:    file #543 (forward the `describe` feature through `gamut-metadata` and `gamut`) and #544 (nothing re-derives the per-tag `Type`/`Count` columns), and document both plainly in README/STATUS
   Rejected: widening the manifest to `crates/gamut-metadata/Cargo.toml` and `crates/gamut/Cargo.toml` - scope is frozen to `crates/gamut-exif/`, and `gamut-metadata` is concurrently changed by PR #509; adding an uncommitted transcription script to the test tier - that puts a `pdftotext` dependency in CI for a check that has to hold at transcription time, which is the trade #544 exists to decide deliberately
   Reverses: close #543 and #544, and drop the README/STATUS notes from ae353b69
   Filed:    #543, #544
   Evidence: for #543, `gamut-metadata`'s manifest has no passthrough feature for `gamut-exif/describe`, so no consumer of the umbrella can enable it. For #544, a *missing* row is caught by the structural guards and the exiv2 name differential, but a *corrected-but-different* `Type` or `Count` compiles, passes every test, and is not a mutant — the columns are data in a macro invocation

Unresolved review notes

  • Manifest revision request (not taken). Meeting decision 4's rendered-value half needs
    tooling/exiv2-oracle/src/shim.cpp and src/lib.rs, which are outside this lane's manifest —
    about fifteen lines of C++ exposing Exiv2::Exifdatum::print() plus a safe Rust wrapper, in a
    dev-only crate with no shipped surface. The lane did not widen its manifest; see decision 5 and
    issue tooling/exiv2-oracle: expose Exifdatum::print() so a rendered-value differential is possible #533.
  • A latent race in the oracle, worked around here. Adding three tests to tests/oracle.rs
    tripled the number of test threads calling exiv2 at once and produced a nondeterministic SIGSEGV
    in CI's mutation baseline. This branch serialises the calls in the test file; the guard belongs
    in tooling/exiv2-oracle beside the one its XMP half already takes, which is outside this lane's
    manifest and is filed as tooling/exiv2-oracle: EXIF entry points are unguarded while the XMP ones are serialised #536. Nobody has captured a faulting stack trace, so the mechanism is
    inferred from the shard-to-shard nondeterminism rather than observed directly.
  • gamut_exif::describe names both a module and a function. The module is pub, and the
    crate root also re-exports the function out of it, so gamut_exif::describe(tag, code) and
    gamut_exif::describe::describe(tag, code) both work — they live in different namespaces, so
    this compiles and rustdoc renders both. It keeps the ergonomic call the README shows and matches
    how every other module's items are re-exported at the root, but it is a wart, and dropping the
    root re-export is the alternative.
  • describe is not surfaced through the gamut umbrella's feature table (crates/gamut/Cargo.toml
    is outside the manifest, and check-ffi-features passes without it). A consumer reaches it by
    depending on gamut-exif directly. Worth a follow-up if the umbrella should expose it.

Round 2

Round 3

Three Low review findings, all of them sentences that described the code's basis more strongly than
the code supports, plus one correction to a filed issue. One commit, docs(exif): state the rule that selects the described tags; the diff is comment- and Markdown-only (git diff -U0 -- crates/gamut-exif/src has no changed line that is not a ///, //! or // line).

  • The drift-guard docstring and STATUS.md were false in both directions. They said "the
    thirty-nine tags for which CIPA DC-008 prints a value table are exactly the tags that have one
    here". The spec prints a table for Flash §4.6.6.7.21, GPSVersionID §4.6.7.1.1,
    FlashpixVersion §4.6.6.1.2, YCbCrSubSampling §4.6.5.1.12 and InteroperabilityIndex
    §4.6.8.1.1, none of which has an arm, and prints none for FocalPlaneResolutionUnit §4.6.6.7.28,
    which does. The number 39 was right; the rule behind it was unstated. Both places now state the
    rule the code implements: a tag has a table exactly when DC-008 fixes the meaning of a single
    scalar code — one integer, or one ASCII character — that the value carries on its own, or, for
    ComponentsConfiguration, that each of its four elements carries on its own.
    The four
    structurally non-scalar tags are named as exclusions with the reason each is one (a bitfield; two
    fixed multi-byte versions; a pair whose meaning belongs to its elements jointly; multi-character
    ASCII codes), and FocalPlaneResolutionUnit is named as the deliberate exception, admitted with
    no table of its own because §4.6.6.7.28 defines it as "the same as the ResolutionUnit"
    (§4.6.5.1.11) rather than restating the values. The pinned set is unchanged: the rule was
    restated to match the code, not the code relaxed to admit the four.
  • The discriminant-instability note claimed an enforcement the type system does not provide. It
    said ExifTag "is #[non_exhaustive], carries no repr, and is reached by no gamut-ffi entry
    point, so nothing may cast it to an integer". Only the last clause is load-bearing:
    #[non_exhaustive] blocks exhaustive matching, not as on a fieldless enum, and an external
    crate can and does cast it. The note now says plainly that nothing stops a cast, that the
    discriminants are not stable, and that callers must not cast, persist or transmit them. The
    warning is kept; the false reassurance is gone.
  • The README generalised the pointer-drop past its mechanism. The writer removes the Exif and
    GPS pointers from the 0th IFD and the Interoperability pointer from the Exif sub-IFD — it keys on
    each pointer's home directory — so one hand-set into any other directory survives as an ordinary
    field that no accessor reads. The README claimed it "drops any that were hand-set"; it now states
    the directory-keyed mechanism. tag.rs's narrower claim was already sound and is unchanged.
  • The lenient-door enumeration now states its boundary. The boundary is doors that admit a
    field; there are nine and all nine are now named (set_tag, set, image_mut, exif_ifd_mut,
    gps_ifd_mut, interop_ifd_mut, set_exif_ifd, set_gps_ifd, set_interop_ifd), where six
    were named and three left to "and their siblings". set_thumbnail is called out separately as
    sitting outside that boundary: it admits bytes rather than a field, and does not check that they
    are a JPEG.
  • Correction to issue gamut-exif: nothing in CI re-derives the per-tag Type/Count columns from CIPA DC-008 #544. That issue says the name check lives in tests/tag_names.rs. There
    is no such file — the check landed in crates/gamut-exif/tests/oracle.rs. Everything else in
    gamut-exif: nothing in CI re-derives the per-tag Type/Count columns from CIPA DC-008 #544 is accurate. Recorded here because this run does not edit existing issues.

Follow-ups filed (both API additions on a crate already at a stable version, so out of scope
here and additive):

Validation for this round, verbatim, at 2c15d96f:

Command Outcome
cargo test -p gamut-exif --all-features pass (all suites, 6 doctests)
RUSTDOCFLAGS="-D warnings" cargo doc -p gamut-exif --no-deps --all-features pass, clean
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass
mise run check-tests pass
mise run check-commits pass, no errors in 23 commits
convco check origin/feat/419-exif-streaming-reader..HEAD pass, no errors in 15 commits

mise run mutants-diff was not run and is not applicable: the round's diff contains no
executable line, so mutation testing has nothing in it to see. mise run lint and mise run test
were not re-run either — no compiled behaviour changed, and the crate's own suite plus a
warnings-denied rustdoc build cover the doc comments this round touches. Earlier rounds' full-gate
results stand above and are not re-claimed here.

Residual risk. The rule now written down is a description of the pinned set, checked by
reading the six named sections of the vendored spec; nothing mechanically derives the set from the
spec, so a future tag whose section prints a table could be added or omitted against the rule and
only a human reading both would notice. That is the same class of gap as #544 and #533, narrowed
but not closed.

Round 3 addendum — final head

The Round 3 validation table above was recorded at 2c15d96f. 8df62326 (the five-versus-four
count fix) landed on top of it, and every one of those gates was re-run at 8df62326 with the same
outcome:

Command Outcome at 8df62326
cargo test -p gamut-exif --all-features pass — 97 + 3 + 5 + 10 + 1 lib/integration tests, 6 doctests, 0 failed
RUSTDOCFLAGS="-D warnings" cargo doc -p gamut-exif --no-deps --all-features pass, clean
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass
mise run check-tests pass
mise run check-commits pass, no errors in 24 commits

The exclusion count defect 8df62326 fixes was introduced by 2c15d96f in this round and caught
before merge; it is recorded here rather than silently corrected, because "the new text asserts a
count the list beside it contradicts" is exactly the failure mode Round 3 set out to remove.

The `exif_tags!` table carried only (IfdKind, id, name), so nothing said
what value shape CIPA DC-008 mandates for a tag: a caller could write a
SHORT where the spec demands RATIONAL, or a two-component DateTime, and
the crate had no way to notice.

Each row now also carries the Type and Count columns of the DC-008 table
that defines the tag - Table 6 (0th IFD), Tables 8 and 9 (Exif sub-IFD),
Table 14 (GPS), Table 16 (Interoperability) - reachable as
`ExifTag::field_types` and `ExifTag::component_count`. `TagCount` spells
the Count column's three shapes (a fixed number, `Any`, and
SubjectArea's "2 or 3 or 4"), and `TagCount::allows` is the whole of its
behaviour.

Nine catalogued tags come from other specifications (ApplicationNotes,
IPTC-NAA, InterColorProfile, Rating, RatingPercent and the DCF-era
Interoperability tags): DC-008 claims nothing for them, so their type
list is empty rather than invented, and a test names exactly that set so
the boundary cannot drift silently.

This is data only - no read or write path consults it yet, so no
behaviour changes.
CIPA DC-008 Table 8 section I gained a block after LensSerialNumber in
Exif 3.0 - ImageTitle, Photographer, ImageEditor, CameraFirmware,
RAWDevelopingSoftware, ImageEditingSoftware and MetadataEditingSoftware
(0xA436-0xA43C, sections 4.6.6.9.8 to 4.6.6.9.14) - which the catalogue
did not carry, so they read back as unnamed tags even though the crate
documents Exif 3.0 as the version it implements.

All seven are ASCII-or-UTF-8 with an open count. `ExifTag` is
`#[non_exhaustive]`, so the additions are minor; they complete the
crate's coverage of the tags DC-008 itself defines.
`Exif::set_tag` writes whatever it is handed, so nothing stopped a
caller emitting a SHORT where CIPA DC-008 demands a RATIONAL, or a
GPSLatitude of two components. Now that each row carries the spec's Type
and Count columns, the write path can say so.

`check_tag` and `set_tag_checked` are the conformance check, and they
live beside the writer on purpose: writing is the only place gamut
authors the bytes, so it is the only place a spec violation is gamut's
to refuse. `Exif::set_tag` and the whole read path are untouched - a
caller reproducing a non-conformant source file must still be able to,
and real files break the spec routinely.

Failures carry `TagConstraintError`, a separate type from `ExifError`:
one reports what is wrong with data being read, the other with a value
being offered for writing. The message quotes CIPA DC-008's own
spellings ("SHORT or LONG", "2 or 3 or 4") so it can be checked against
the specification without a lookup table.

`spec_type_name` maps the on-disk type *code* rather than matching
`FieldType`'s variants: gamut-ifd grows three BigTIFF variants when
another workspace crate turns that feature on and Cargo unifies it into
this build, so a variant match would compile in one configuration and
not the other.
`ExifTag::name` was the only string in the crate: nothing turned
`Orientation = 1`, `ResolutionUnit = 2` or the LightSource and
MeteringMode enumerations into anything a person can read, which is what
separates a metadata library that can display from one that can only
round-trip.

The new `describe` module carries CIPA DC-008's own wording for every
tag the specification enumerates - not exiftool's or exiv2's phrasing,
which would put an oracle where the source of truth belongs.
`described_values` publishes a tag's whole defined domain and `describe`
is exactly a lookup into it, so what counts as a reserved code has one
definition rather than two. `Flash` is a bitfield, not an enumeration,
so `flash` decomposes Figure 17's five fields instead of tabulating
every bit combination.

It sits behind an opt-in `describe` feature, default off: the table is
several kilobytes of static strings that a consumer which only writes or
only round-trips metadata never reads, and the workspace is
allocation-conscious by convention. The feature is additive, so no
existing build grows; `full` turns it on.
The tag catalogue's names had no external check: a mis-transcribed name
would have been asserted only against itself. exiv2, the crate's
documented oracle, knows a canonical name for every standard tag, so
writing every catalogued tag into a stream and asking exiv2 for
`Exif.<group>.<name>` decides whether gamut's name is the one the
reference implementation uses.

All 160 names resolve except three, which the test pins as an exact set
so the divergence can neither grow nor shrink unnoticed, and a second
test proves each is a naming difference rather than a missing tag by
reading the same value back under exiv2's own spelling:

  0x02BC  ApplicationNotes  vs exiv2's XMLPacket
  0x83BB  IPTC-NAA          vs exiv2's IPTCNAA
  0x8827  PhotographicSensitivity vs exiv2's ISOSpeedRatings

CIPA DC-008 renamed 0x8827 in Exif 2.3 and exiv2 keeps the Exif 2.2
name; the other two are TIFF/EP-lineage tags no vendored spec defines.
DC-008 wins in each case - it is the specification this crate
implements, and exiv2 is the oracle, not the source.

exiv2 also resolves all seven Exif 3.0 authorship tags this branch
added, which is independent confirmation of the ids and names.
STATUS.md and README.md still described the tag dictionary as a partial
"standard dictionary" with exiftool-parity breadth deferred. The
catalogue is now complete for CIPA DC-008 - 151 tags the spec defines
plus nine carried from other specifications - so the deferred item is no
longer breadth within DC-008 but breadth beyond any vendored spec, which
means transcribing from those specifications rather than copying a table
out of the oracle.

Adds a P10 phase describing what the catalogue covers, the write-only
conformance check, and the `describe` feature; records the three tags
where exiv2's name and DC-008's disagree, with both readings, so a
reader can see why gamut keeps the spec's; and names the one piece of
the issue's acceptance that could not be met - the oracle shim exposes
exiv2's raw value but not its interpreted one, so the description tables
are checked structurally rather than against exiv2's rendering.
Every other assertion about the description tables reads one through
`described_values`, so an arm that lost its rows would make those
assertions vacuous rather than failing them. This names the exact set of
tags that have a table, which is the one thing that notices.

Also guards the oracle helper's string length against underflow: a
component count includes the terminating NUL, so a count of zero would
have wrapped in a profile without overflow checks instead of failing
loudly.
CIPA DC-008 contradicts itself on 0xA407. Table 9's Type column, from
which the rest of the catalogue's types were transcribed, says RATIONAL;
§4.6.6.7.41 says SHORT and enumerates five integer codes, 0 for None
through 4 for High gain down.

The tag's own section wins. A fraction cannot carry an enumeration, and
both exiv2 and ExifTool read the tag as SHORT, so RATIONAL in the
summary table is an erratum. Left as it was, the validating setter would
have refused the only values the tag can actually hold.

Found by cross-checking all 149 specified rows against the per-tag
`Tag = / Type = / Count =` blocks the specification states beside each
definition; this was the only contradiction between the two.
…be feature

The crate-level documentation is the front door, and it described only
the read path. A caller had no way to discover from it that a tag now
carries the value shape CIPA DC-008 mandates, that `set_tag_checked`
enforces it while `set_tag` deliberately does not, or that the enumerated
values have descriptions behind a feature.

The `describe` names are written as code rather than intra-doc links:
the module does not exist when the feature is off, so a link there would
be broken in a default build.

Also drops the `exif_tags!` link in the tag module's docs, on a sentence
this branch already rewrites. `macro_rules!` items are not in scope at
that path, so rustdoc could never resolve it.
The tag-name differential added three tests to tests/oracle.rs, taking
it from two `#[test]`s to five. `cargo test` runs them on separate
threads, and they are the workspace's only callers of the oracle's EXIF
entry points, so exiv2 started being entered concurrently for the first
time. It segfaulted.

The failure is a race, not a bad input: of the four mutation shards CI
ran on one commit, each of which runs the identical unmutated baseline,
three passed and one died with SIGSEGV before any test reported. Same
tree, same exiv2 build, same bytes.

`exiv2-oracle` already guards its XMP entry points for exactly this
reason — XMPCore keeps global state and is documented as not
thread-safe — and leaves its EXIF entry points unguarded, with nothing
recording that as a decision. Until that is settled, every exiv2 call
this file makes goes through one lock, so exiv2 only ever runs
single-threaded here.

This is a work-around at the call site: it protects one file, and the
next caller of `exif_get` will not know to bring its own lock. The guard
belongs in the oracle crate beside the XMP one, filed as #536.
The table said "exiv2 0.28". The submodule pins v0.28.8, and issue #417
quotes 0.28.9's tag count, so a reader comparing the two had no way to
tell which build produced these three readings.
STATUS.md said nine tags were "carried from other specifications for
compatibility" without saying which, so a reader could not check them
against the workspace rule that a row comes from a specification under
references/.

Three of the nine do: ApplicationNotes and IPTC-NAA from the TIFF table
in the vendored XMP Part 3, and InterColorProfile from ICC.1:2001-04,
which states the tag number outright.

Six do not, and are now named as residuals. DC-008 3.0 lists the four
DCF-era Interoperability tags in its original-preservation-image annex
but delegates them to DCF Table 13 section 4.7, and DCF is not vendored;
Rating and RatingPercent are a Microsoft extension with no vendored text
at all. That is precisely why their `field_types` is empty - nothing
here fixes a type or count for them, so nothing is claimed. Vendoring
DCF would settle four of the six.

All nine predate this phase; it only made their status explicit.
`describe` answered `None` for GPSLatitudeRef, GPSLongitudeRef, GPSSpeedRef,
GPSTrackRef, GPSImgDirectionRef, GPSDestLatitudeRef, GPSDestLongitudeRef,
GPSDestBearingRef and GPSDestDistanceRef, though §4.6.7.1 enumerates each in
exactly the character-coded shape GPSStatus and GPSMeasureMode already use.
Since the module documents `None` as "the spec reserves that code", the crate
was stating something false about the specification: `describe(GpsSpeedRef, 'K')`
was `None` where DC-008 says "Kilometers per hour".

Transcribe all nine from the vendored spec text, bringing the coverage to the
39 tags for which DC-008 prints a value table. The drift guard is corrected in
the same commit: it pinned the 30 names the table happened to hold and was
worded as if that were what the spec enumerates, so it asserted a false
proposition and blocked the fix.

A missing match arm is not a mutant, so neither the structural checks nor the
mutation gate could have seen this. The one new test covers the class that
replaces them: these tags reuse the same few letters for unrelated meanings -
'M' is a magnetic direction, miles per hour, or miles - so an arm that swallowed
one tag into a sibling's group would answer plausibly and wrongly while every
existing assertion still held.
Four claims were wider than the code:

- The README said the catalogue holds "every tag CIPA DC-008 defines". It holds
  every tag in the five tables that define one; §4.6.3.1.1, §4.6.3.2.1 and
  §4.6.3.3.1 give the three IFD-pointer tags their own Tag/Type/Count blocks
  outside those tables. Those stay uncatalogued on purpose - the writer
  synthesises them from the tree and removes any that were hand-set, so naming
  them would let `set_tag_checked` accept a pointer that is then discarded.
  Say so where a reader would ask, at the catalogue and in the README.
- `lib.rs` said every catalogued tag carries a spec-mandated type and count.
  False for the nine rows DC-008 does not define, whose `field_types` is empty
  by design; match `tag.rs`'s precise wording.
- `lib.rs` and `writer.rs` each named one lenient write door where six are
  public. Enumerate them: `set_tag`, `set`, `exif_ifd_mut`, `gps_ifd_mut`,
  `interop_ifd_mut` and `set_gps_ifd`, plus the siblings reaching the same
  directories.
- `set_tag_checked` refuses a RATIONAL `GainControl`, which Table 9 literally
  permits, because §4.6.6.7.41 says SHORT and enumerates five integer codes.
  Name the contradiction on the setter itself and point at `Exif::set_tag` as
  the escape hatch, so a caller reproducing a Table-9-conformant file is not
  left guessing.

Also record two properties nobody had written down: `ExifTag`'s discriminants
are not stable, because variants are declared in the specification's order and
`ALL` iterates it, and the `describe` feature is reachable only from a direct
dependent (#543). `TagConstraintError` is deliberately parallel to `ExifError`
rather than a variant of it, as `GpsConversionError` already is. The `Type` and
`Count` columns are hand-transcribed and nothing re-derives them, so a
corrected-but-different value would pass every check (#544).
The drift-guard docstring and STATUS.md said the thirty-nine tags with a
table here are exactly those for which CIPA DC-008 prints a value table.
That is false in both directions: the spec prints one for Flash,
GPSVersionID, FlashpixVersion, YCbCrSubSampling and InteroperabilityIndex,
none of which has an arm, and prints none for FocalPlaneResolutionUnit,
which does. State the rule the code implements instead - a single scalar
code, per value or per ComponentsConfiguration element - name the four
structurally non-scalar exclusions, and name FocalPlaneResolutionUnit as
the deliberate exception with the cross-reference that justifies it.

The discriminant-instability note claimed nothing may cast ExifTag to an
integer because it is non_exhaustive, carries no repr and is unreachable
from gamut-ffi. Only the last is load-bearing: non_exhaustive blocks
exhaustive matching, not `as` on a fieldless enum. Keep the warning, drop
the false reassurance.

The README generalised the pointer-drop past its mechanism: the writer
keys on each pointer's home directory, so one hand-set elsewhere survives.
Narrow the claim to that. State the lenient-door boundary too - doors that
admit a field, of which there are nine - and note set_thumbnail separately
as admitting unvalidated bytes with no field.
The drift-guard docstring and STATUS.md each say "Four tags whose sections
print one are excluded" and then name five: Flash, GPSVersionID,
FlashpixVersion, YCbCrSubSampling and InteroperabilityIndex. The module docs
get it right by counting Flash separately - "Four more tags" follows the
sentence that names it - but these two fold Flash into the list without
adjusting the number, so each contradicts the list beside it.

Say five. Also rewrap the one prose line that overran, leaving STATUS.md's only
long lines the table rows that cannot be wrapped.
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